From e97982be4d861f7d551981ead5f3ad75192d9be0 Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Mohsin Rajan Date: Tue, 13 Jul 2021 16:31:42 +0530 Subject: [PATCH 001/151] fix: user permission for restricting nested struct --- frappe/core/doctype/user_permission/user_permission.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/core/doctype/user_permission/user_permission.py b/frappe/core/doctype/user_permission/user_permission.py index 4aa5797c7f..8837a4bd7e 100644 --- a/frappe/core/doctype/user_permission/user_permission.py +++ b/frappe/core/doctype/user_permission/user_permission.py @@ -49,7 +49,6 @@ class UserPermission(Document): }, or_filters={ 'applicable_for': cstr(self.applicable_for), 'apply_to_all_doctypes': 1, - 'hide_descendants': cstr(self.hide_descendants) }, limit=1) if overlap_exists: ref_link = frappe.get_desk_link(self.doctype, overlap_exists[0].name) From f071472b7209d9312a6a2d54e71aa1b5d2a2e89d Mon Sep 17 00:00:00 2001 From: hasnain2808 Date: Tue, 31 Aug 2021 12:51:21 +0530 Subject: [PATCH 002/151] test: or_filters affecting default validation --- .../core/doctype/user_permission/test_user_permission.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py index 85db846982..ccc9df9851 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -31,6 +31,15 @@ class TestUserPermission(unittest.TestCase): param = get_params(user, 'User', perm_user.name, is_default=1) self.assertRaises(frappe.ValidationError, add_user_permissions, param) + def test_default_user_permission_corectness(self): + user = create_user('test_default_corectness_permission_1@example.com') + param = get_params(user, 'User', user.name, is_default=1, hide_descendants= 1) + add_user_permissions(param) + #create a duplicate entry with default + perm_user = create_user('test_default_corectness2@example.com') + param = get_params(perm_user, 'Blog Post', perm_user.name, is_default=1, hide_descendants= 1) + add_user_permissions(param) + def test_default_user_permission(self): frappe.set_user('Administrator') user = create_user('test_user_perm1@example.com', 'Website Manager') From 44f603fc6e1be5495f76eb777042e09c1ae9d822 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 27 Aug 2021 20:32:23 +0530 Subject: [PATCH 003/151] refactor: frappe.db.set_value * Simplified logic * Perf enhancements (removed unnecessary conditional computations) * Use query builder and ORM to build queries instead of juggling --- frappe/database/database.py | 68 ++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index 65242e0419..fc8bb8321d 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -677,46 +677,44 @@ class Database(object): :param debug: Print the query in the developer / js console. :param for_update: Will add a row-level lock to the value that is being set so that it can be released on commit. """ - if not modified: - modified = now() - if not modified_by: - modified_by = frappe.session.user + is_single_doctype = not (dn and dt != dn) + to_update = field if isinstance(field, dict) else {field: val} - to_update = {} if update_modified: - to_update = {"modified": modified, "modified_by": modified_by} + modified = modified or now() + modified_by = modified_by or frappe.session.user + to_update.update({"modified": modified, "modified_by": modified_by}) + + if not is_single_doctype: + docnames = tuple(x[0] for x in self.get_values(dt, dn, 'name', debug=debug, for_update=for_update)) + if not docnames: + if debug: + print("Matched with no rows...exitting") + return + + table = frappe.qb.DocType(dt) + + query = frappe.qb.update(table) + for column, value in to_update.items(): + query = query.set(column, value) + query = query.where(table.name.isin(docnames)) + query.run(debug=debug) + + for d in docnames: + frappe.clear_document_cache(dt, d) - if isinstance(field, dict): - to_update.update(field) else: - to_update.update({field: val}) + frappe.db.delete( + "Singles", + filters={"field": ("in", tuple(to_update)), "doctype": dt}, debug=debug + ) - if dn and dt!=dn: - # with table - set_values = [] - for key in to_update: - set_values.append('`{0}`=%({0})s'.format(key)) - - for name in self.get_values(dt, dn, 'name', for_update=for_update, debug=debug): - values = dict(name=name[0]) - values.update(to_update) - - self.sql("""update `tab{0}` - set {1} where name=%(name)s""".format(dt, ', '.join(set_values)), - values, debug=debug) - - frappe.clear_document_cache(dt, values['name']) - else: - # for singles - keys = list(to_update) - self.sql(''' - delete from `tabSingles` - where field in ({0}) and - doctype=%s'''.format(', '.join(['%s']*len(keys))), - list(keys) + [dt], debug=debug) - for key, value in to_update.items(): - self.sql('''insert into `tabSingles` (doctype, field, value) values (%s, %s, %s)''', - (dt, key, value), debug=debug) + singles_data = tuple((dt, key, value) for key, value in to_update.items()) + query = ( + frappe.qb.into("Singles") + .columns("doctype", "field", "value") + .insert(singles_data) + ).run(debug=debug) frappe.clear_document_cache(dt, dn) From aefb8e3b06019edf5e9bef9d9ff1dc38624117be Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 8 Oct 2021 11:00:55 +0530 Subject: [PATCH 004/151] fix: Expand iterable to get rid of extra brackets Although valid SQL, MariaDB didn't like double brackets on VALUES. It raised ERROR 1241: Operand should contain 1 column(s) --- frappe/database/database.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index fc8bb8321d..35ee503ace 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -709,11 +709,11 @@ class Database(object): filters={"field": ("in", tuple(to_update)), "doctype": dt}, debug=debug ) - singles_data = tuple((dt, key, value) for key, value in to_update.items()) + singles_data = ((dt, key, value) for key, value in to_update.items()) query = ( frappe.qb.into("Singles") .columns("doctype", "field", "value") - .insert(singles_data) + .insert(*singles_data) ).run(debug=debug) frappe.clear_document_cache(dt, dn) From 697d6f2d7aeb75663e5c9487273f50c1d3d9fb86 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 22 Oct 2021 19:19:54 +0530 Subject: [PATCH 005/151] fix: Run query even if no documents found for_update If no docnames are found, then use NULL as fallback. I tested this on PostgreSQL & MariaDB and it seems to work as expected In [1]: from frappe.query_builder import Field In [2]: frappe.db.set_value('ToDo', Field("creation") > Field("modified"), 'description', 'change 2', for_update=True, debug=True) SELECT "name" FROM "tabToDo" WHERE "creation">"modified" Execution time: 0.0 sec UPDATE "tabToDo" SET "description"='change 2',"modified"='2021-10-22 09:38:40.110481',"modified_by"='Administrator' WHERE "name" IN (NULL) Execution time: 0.0 sec --- frappe/database/database.py | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index 35ee503ace..3de6c8b2ae 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -20,9 +20,9 @@ from frappe.utils import now, getdate, cast, get_datetime from frappe.model.utils.link_count import flush_local_link_count from frappe.query_builder.functions import Count from frappe.query_builder.functions import Min, Max, Avg, Sum -from frappe.query_builder.utils import Column +from frappe.query_builder.utils import Column, DocType from .query import Query -from pypika.terms import Criterion, PseudoColumn +from pypika.terms import Criterion, PseudoColumn, NullValue class Database(object): @@ -686,18 +686,20 @@ class Database(object): to_update.update({"modified": modified, "modified_by": modified_by}) if not is_single_doctype: - docnames = tuple(x[0] for x in self.get_values(dt, dn, 'name', debug=debug, for_update=for_update)) - if not docnames: - if debug: - print("Matched with no rows...exitting") - return + table = DocType(dt) - table = frappe.qb.DocType(dt) + if for_update: + docnames = tuple( + x[0] for x in self.get_values(dt, dn, "name", debug=debug, for_update=for_update) + ) or (NullValue(),) + query = frappe.qb.update(table).where(table.name.isin(docnames)) + + else: + query = self.query.build_conditions(table=dt, filters=dn, update=True) - query = frappe.qb.update(table) for column, value in to_update.items(): query = query.set(column, value) - query = query.where(table.name.isin(docnames)) + query.run(debug=debug) for d in docnames: From b0c30363cf4bc0e73711e29a6f676dc70b925147 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 22 Oct 2021 20:18:17 +0530 Subject: [PATCH 006/151] fix: Cast values as str for all single doctypes Re-arranged block for simplicity. Type casting doesn't change anything it seems. On get_value, values are casted properly. --- frappe/database/database.py | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index 3de6c8b2ae..ee3ad175b1 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -685,7 +685,21 @@ class Database(object): modified_by = modified_by or frappe.session.user to_update.update({"modified": modified, "modified_by": modified_by}) - if not is_single_doctype: + if is_single_doctype: + frappe.db.delete( + "Singles", + filters={"field": ("in", tuple(to_update)), "doctype": dt}, debug=debug + ) + + singles_data = ((dt, key, str(value)) for key, value in to_update.items()) + query = ( + frappe.qb.into("Singles") + .columns("doctype", "field", "value") + .insert(*singles_data) + ).run(debug=debug) + frappe.clear_document_cache(dt, dn) + + else: table = DocType(dt) if for_update: @@ -702,24 +716,6 @@ class Database(object): query.run(debug=debug) - for d in docnames: - frappe.clear_document_cache(dt, d) - - else: - frappe.db.delete( - "Singles", - filters={"field": ("in", tuple(to_update)), "doctype": dt}, debug=debug - ) - - singles_data = ((dt, key, value) for key, value in to_update.items()) - query = ( - frappe.qb.into("Singles") - .columns("doctype", "field", "value") - .insert(*singles_data) - ).run(debug=debug) - - frappe.clear_document_cache(dt, dn) - if dt in self.value_cache: del self.value_cache[dt] From 123bcc95098612af1b013cccdd5eb70c2c9ee929 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 7 Jan 2022 14:16:45 +0530 Subject: [PATCH 007/151] fix: Clear cache on set_value --- frappe/database/database.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index ee3ad175b1..05169364f0 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -10,19 +10,20 @@ import re import string from contextlib import contextmanager from time import time -from typing import Dict, List, Union, Tuple +from typing import Dict, List, Tuple, Union + +from pypika.terms import Criterion, NullValue, PseudoColumn import frappe import frappe.defaults import frappe.model.meta from frappe import _ -from frappe.utils import now, getdate, cast, get_datetime from frappe.model.utils.link_count import flush_local_link_count from frappe.query_builder.functions import Count -from frappe.query_builder.functions import Min, Max, Avg, Sum -from frappe.query_builder.utils import Column, DocType +from frappe.query_builder.utils import DocType +from frappe.utils import cast, get_datetime, getdate, now + from .query import Query -from pypika.terms import Criterion, PseudoColumn, NullValue class Database(object): @@ -697,19 +698,26 @@ class Database(object): .columns("doctype", "field", "value") .insert(*singles_data) ).run(debug=debug) - frappe.clear_document_cache(dt, dn) + frappe.clear_document_cache(dt, dt) else: table = DocType(dt) if for_update: docnames = tuple( - x[0] for x in self.get_values(dt, dn, "name", debug=debug, for_update=for_update) + self.get_values(dt, dn, "name", debug=debug, for_update=for_update, pluck=True) ) or (NullValue(),) query = frappe.qb.update(table).where(table.name.isin(docnames)) + for docname in docnames: + frappe.clear_document_cache(dt, docname) + else: query = self.query.build_conditions(table=dt, filters=dn, update=True) + # TODO: Fix this; doesn't work rn - gavin@frappe.io + # frappe.cache().hdel_keys(dt, "document_cache") + # Workaround: clear all document caches + frappe.cache().delete_value('document_cache') for column, value in to_update.items(): query = query.set(column, value) @@ -719,7 +727,6 @@ class Database(object): if dt in self.value_cache: del self.value_cache[dt] - @staticmethod def set(doc, field, val): """Set value in document. **Avoid**""" From 31cc658e6d9d8febd06be34979198ea2cfe604ff Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 7 Jan 2022 14:17:28 +0530 Subject: [PATCH 008/151] fix(typing): Add type hints for frappe.cache --- frappe/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index 08c0f794b3..d07223a170 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -140,6 +140,8 @@ lang = local("lang") # This if block is never executed when running the code. It is only used for # telling static code analyzer where to find dynamically defined attributes. if typing.TYPE_CHECKING: + from frappe.utils.redis_wrapper import RedisWrapper + from frappe.database.mariadb.database import MariaDBDatabase from frappe.database.postgres.database import PostgresDatabase from frappe.query_builder.builder import MariaDB, Postgres @@ -147,6 +149,7 @@ if typing.TYPE_CHECKING: db: typing.Union[MariaDBDatabase, PostgresDatabase] qb: typing.Union[MariaDB, Postgres] + # end: static analysis hack def init(site, sites_path=None, new_site=False): @@ -310,7 +313,7 @@ def destroy(): # memcache redis_server = None -def cache(): +def cache() -> "RedisWrapper": """Returns redis connection.""" global redis_server if not redis_server: From 34a6f7adb5019a906d57ff44e1e604383d619bf9 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 7 Jan 2022 20:43:50 +0530 Subject: [PATCH 009/151] fix(qb): Patch ParameterizedValueWrapper as wrapper_cls --- frappe/query_builder/builder.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/frappe/query_builder/builder.py b/frappe/query_builder/builder.py index a65d50fdeb..8ae8324de4 100644 --- a/frappe/query_builder/builder.py +++ b/frappe/query_builder/builder.py @@ -1,8 +1,12 @@ from pypika import MySQLQuery, Order, PostgreSQLQuery, terms +from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder from pypika.queries import Schema, Table -from frappe.utils import get_table_name from pypika.terms import Function +from frappe.query_builder.terms import ParameterizedValueWrapper +from frappe.utils import get_table_name + + class Base: terms = terms desc = Order.desc @@ -34,6 +38,10 @@ class Base: class MariaDB(Base, MySQLQuery): Field = terms.Field + @classmethod + def _builder(cls, *args, **kwargs) -> "MySQLQueryBuilder": + return super()._builder(*args, wrapper_cls=ParameterizedValueWrapper, **kwargs) + @classmethod def from_(cls, table, *args, **kwargs): if isinstance(table, str): @@ -53,6 +61,10 @@ class Postgres(Base, PostgreSQLQuery): # they are two different objects. The quick fix used here is to replace the # Field names in the "Field" function. + @classmethod + def _builder(cls, *args, **kwargs) -> "PostgreSQLQueryBuilder": + return super()._builder(*args, wrapper_cls=ParameterizedValueWrapper, **kwargs) + @classmethod def Field(cls, field_name, *args, **kwargs): if field_name in cls.field_translation: From 6aa9a0bef5b6003df8de4729a41cc0c3d90c9786 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 7 Jan 2022 20:44:51 +0530 Subject: [PATCH 010/151] style: Sorted imports & whitespace consistency --- frappe/query_builder/utils.py | 14 +++++++------- frappe/utils/__init__.py | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frappe/query_builder/utils.py b/frappe/query_builder/utils.py index 2767e90242..7797ce856c 100644 --- a/frappe/query_builder/utils.py +++ b/frappe/query_builder/utils.py @@ -1,17 +1,17 @@ from enum import Enum -from typing import Any, Callable, Dict, Union, get_type_hints from importlib import import_module +from typing import Any, Callable, Dict, Union, get_type_hints from pypika import Query from pypika.queries import Column - -import frappe - -from .builder import MariaDB, Postgres from pypika.terms import PseudoColumn +import frappe from frappe.query_builder.terms import NamedParameterWrapper +from .builder import MariaDB, Postgres + + class db_type_is(Enum): MARIADB = "mariadb" POSTGRES = "postgres" @@ -60,7 +60,7 @@ def patch_query_execute(): def prepare_query(query): params = {} - query = query.get_sql(param_wrapper = NamedParameterWrapper(params)) + query = query.get_sql(param_wrapper=NamedParameterWrapper(params)) if frappe.flags.in_safe_exec and not query.lower().strip().startswith("select"): raise frappe.PermissionError('Only SELECT SQL allowed in scripting') return query, params @@ -78,7 +78,7 @@ def patch_query_execute(): def patch_query_aggregation(): """Patch aggregation functions to frappe.qb """ - from frappe.query_builder.functions import _max, _min, _avg, _sum + from frappe.query_builder.functions import _avg, _max, _min, _sum frappe.qb.max = _max frappe.qb.min = _min diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index ffff7032aa..e3ddd5ae06 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -56,8 +56,8 @@ def get_email_address(user=None): def get_formatted_email(user, mail=None): """get Email Address of user formatted as: `John Doe `""" fullname = get_fullname(user) - method = get_hook_method('get_sender_details') + if method: sender_name, mail = method() # if method exists but sender_name is "" From 5de0b5cd352831700b449113ba80c677dd3896ce Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 10 Jan 2022 13:23:49 +0530 Subject: [PATCH 011/151] style: remove commented code, whitespaces test_file, added typing hint --- frappe/core/doctype/file/test_file.py | 27 ++------------------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 2c1042e104..3ae5b87128 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -7,9 +7,8 @@ import frappe import os import unittest from frappe import _ -from frappe.core.doctype.file.file import get_attached_images, move_file, get_files_in_folder, unzip_file +from frappe.core.doctype.file.file import File, get_attached_images, move_file, get_files_in_folder, unzip_file from frappe.utils import get_files_path -# test_records = frappe.get_test_records('File') test_content1 = 'Hello' test_content2 = 'Hello World' @@ -24,8 +23,6 @@ def make_test_doc(): class TestSimpleFile(unittest.TestCase): - - def setUp(self): self.attached_to_doctype, self.attached_to_docname = make_test_doc() self.test_content = test_content1 @@ -38,21 +35,13 @@ class TestSimpleFile(unittest.TestCase): _file.save() self.saved_file_url = _file.file_url - def test_save(self): _file = frappe.get_doc("File", {"file_url": self.saved_file_url}) content = _file.get_content() self.assertEqual(content, self.test_content) - def tearDown(self): - # File gets deleted on rollback, so blank - pass - - class TestBase64File(unittest.TestCase): - - def setUp(self): self.attached_to_doctype, self.attached_to_docname = make_test_doc() self.test_content = base64.b64encode(test_content1.encode('utf-8')) @@ -66,18 +55,12 @@ class TestBase64File(unittest.TestCase): _file.save() self.saved_file_url = _file.file_url - def test_saved_content(self): _file = frappe.get_doc("File", {"file_url": self.saved_file_url}) content = _file.get_content() self.assertEqual(content, test_content1) - def tearDown(self): - # File gets deleted on rollback, so blank - pass - - class TestSameFileName(unittest.TestCase): def test_saved_content(self): self.attached_to_doctype, self.attached_to_docname = make_test_doc() @@ -130,8 +113,6 @@ class TestSameFileName(unittest.TestCase): class TestSameContent(unittest.TestCase): - - def setUp(self): self.attached_to_doctype1, self.attached_to_docname1 = make_test_doc() self.attached_to_doctype2, self.attached_to_docname2 = make_test_doc() @@ -186,10 +167,6 @@ class TestSameContent(unittest.TestCase): limit_property.delete() frappe.clear_cache(doctype='ToDo') - def tearDown(self): - # File gets deleted on rollback, so blank - pass - class TestFile(unittest.TestCase): def setUp(self): @@ -398,7 +375,7 @@ class TestFile(unittest.TestCase): def test_make_thumbnail(self): # test web image - test_file = frappe.get_doc({ + test_file: File = frappe.get_doc({ "doctype": "File", "file_name": 'logo', "file_url": frappe.utils.get_url('/_test/assets/image.jpg'), From 04e79eb0750bfb465391a4b102f0dd8f37c902f0 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 10 Jan 2022 13:25:47 +0530 Subject: [PATCH 012/151] fix(qb:ValueWrapper): Use get_value_sql only for str values --- frappe/query_builder/terms.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/query_builder/terms.py b/frappe/query_builder/terms.py index 2032cd8497..0e0e2c4800 100644 --- a/frappe/query_builder/terms.py +++ b/frappe/query_builder/terms.py @@ -21,7 +21,10 @@ class ParameterizedValueWrapper(ValueWrapper): sql = self.get_value_sql(quote_char=quote_char, secondary_quote_char=secondary_quote_char, **kwargs) return format_alias_sql(sql, self.alias, quote_char=quote_char, **kwargs) else: - value_sql = self.get_value_sql(quote_char=quote_char, **kwargs) if not isinstance(self.value,int) else self.value + if isinstance(self.value, str): + value_sql = self.get_value_sql(quote_char=quote_char, **kwargs) + else: + value_sql = self.value param_sql = param_wrapper.get_sql(**kwargs) param_wrapper.update_parameters(param_key=param_sql, param_value=value_sql, **kwargs) return format_alias_sql(param_sql, self.alias, quote_char=quote_char, **kwargs) From f6fba91fd203c33218f0f2fe64b4aaee3772f49f Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 10 Jan 2022 13:44:23 +0530 Subject: [PATCH 013/151] chore(typing): Add type hints in qb builder classes --- frappe/query_builder/builder.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/query_builder/builder.py b/frappe/query_builder/builder.py index 8ae8324de4..d2fdeab324 100644 --- a/frappe/query_builder/builder.py +++ b/frappe/query_builder/builder.py @@ -1,6 +1,6 @@ from pypika import MySQLQuery, Order, PostgreSQLQuery, terms from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder -from pypika.queries import Schema, Table +from pypika.queries import QueryBuilder, Schema, Table from pypika.terms import Function from frappe.query_builder.terms import ParameterizedValueWrapper @@ -23,13 +23,13 @@ class Base: return Table(table_name, *args, **kwargs) @classmethod - def into(cls, table, *args, **kwargs): + def into(cls, table, *args, **kwargs) -> QueryBuilder: if isinstance(table, str): table = cls.DocType(table) return super().into(table, *args, **kwargs) @classmethod - def update(cls, table, *args, **kwargs): + def update(cls, table, *args, **kwargs) -> QueryBuilder: if isinstance(table, str): table = cls.DocType(table) return super().update(table, *args, **kwargs) From 30528080480fde90a78eadf1fc9f2b136e1f26b7 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 10 Jan 2022 15:05:57 +0530 Subject: [PATCH 014/151] fix: Don't cast to str if None or Falsy * refactor: use get_single_value instead of set_value(blah, None, blah1) * User.reload in test_home_page to combat what happens locally --- frappe/database/database.py | 2 +- frappe/tests/test_website.py | 1 + frappe/website/utils.py | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index 05169364f0..28ae177885 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -692,7 +692,7 @@ class Database(object): filters={"field": ("in", tuple(to_update)), "doctype": dt}, debug=debug ) - singles_data = ((dt, key, str(value)) for key, value in to_update.items()) + singles_data = ((dt, key, str(value) if value else value) for key, value in to_update.items()) query = ( frappe.qb.into("Singles") .columns("doctype", "field", "value") diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py index 992d876243..cfadb09a04 100644 --- a/frappe/tests/test_website.py +++ b/frappe/tests/test_website.py @@ -20,6 +20,7 @@ class TestWebsite(unittest.TestCase): doctype='User', email='test-user-for-home-page@example.com', first_name='test')).insert(ignore_if_duplicate=True) + user.reload() role = frappe.get_doc(dict( doctype = 'Role', diff --git a/frappe/website/utils.py b/frappe/website/utils.py index cb8008277c..7beefc8164 100644 --- a/frappe/website/utils.py +++ b/frappe/website/utils.py @@ -88,7 +88,7 @@ def get_home_page(): # portal default if not home_page: - home_page = frappe.db.get_value("Portal Settings", None, "default_portal_home") + home_page = frappe.db.get_single_value("Portal Settings", "default_portal_home") # by hooks if not home_page: @@ -96,7 +96,7 @@ def get_home_page(): # global if not home_page: - home_page = frappe.db.get_value("Website Settings", None, "home_page") + home_page = frappe.db.get_single_value("Website Settings", "home_page") if not home_page: home_page = "login" if frappe.session.user == 'Guest' else "me" From f7cb090c5a763b1da05276d217ffda947ffa1e55 Mon Sep 17 00:00:00 2001 From: saxenabhishek Date: Mon, 10 Jan 2022 16:09:53 +0530 Subject: [PATCH 015/151] style: test_query_builder --- frappe/tests/test_query_builder.py | 33 +++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/frappe/tests/test_query_builder.py b/frappe/tests/test_query_builder.py index d2242cc6f7..ab1d8292b8 100644 --- a/frappe/tests/test_query_builder.py +++ b/frappe/tests/test_query_builder.py @@ -25,8 +25,14 @@ class TestCustomFunctionsMariaDB(unittest.TestCase): ) def test_constant_column(self): - query = frappe.qb.from_("DocType").select("name", ConstantColumn("John").as_("User")) - self.assertEqual(query.get_sql(), "SELECT `name`,'John' `User` FROM `tabDocType`") + query = frappe.qb.from_("DocType").select( + "name", ConstantColumn("John").as_("User") + ) + self.assertEqual( + query.get_sql(), "SELECT `name`,'John' `User` FROM `tabDocType`" + ) + + @run_only_if(db_type_is.POSTGRES) class TestCustomFunctionsPostgres(unittest.TestCase): def test_concat(self): @@ -39,8 +45,13 @@ class TestCustomFunctionsPostgres(unittest.TestCase): ) def test_constant_column(self): - query = frappe.qb.from_("DocType").select("name", ConstantColumn("John").as_("User")) - self.assertEqual(query.get_sql(), 'SELECT "name",\'John\' "User" FROM "tabDocType"') + query = frappe.qb.from_("DocType").select( + "name", ConstantColumn("John").as_("User") + ) + self.assertEqual( + query.get_sql(), 'SELECT "name",\'John\' "User" FROM "tabDocType"' + ) + class TestBuilderBase(object): def test_adding_tabs(self): @@ -56,12 +67,13 @@ class TestBuilderBase(object): self.assertIsInstance(data, list) def test_walk(self): - DocType = frappe.qb.DocType('DocType') + DocType = frappe.qb.DocType("DocType") query = ( frappe.qb.from_(DocType) .select(DocType.name) - .where((DocType.owner == "Administrator' --") - & (Coalesce(DocType.search_fields == "subject")) + .where( + (DocType.owner == "Administrator' --") + & (Coalesce(DocType.search_fields == "subject")) ) ) self.assertTrue("walk" in dir(query)) @@ -69,9 +81,9 @@ class TestBuilderBase(object): self.assertIn("%(param1)s", query) self.assertIn("%(param2)s", query) - self.assertIn("param1",params) - self.assertEqual(params["param1"],"Administrator' --") - self.assertEqual(params["param2"],"subject") + self.assertIn("param1", params) + self.assertEqual(params["param1"], "Administrator' --") + self.assertEqual(params["param2"], "subject") @run_only_if(db_type_is.MARIADB) @@ -84,6 +96,7 @@ class TestBuilderMaria(unittest.TestCase, TestBuilderBase): "SELECT * FROM `__Auth`", frappe.qb.from_("__Auth").select("*").get_sql() ) + @run_only_if(db_type_is.POSTGRES) class TestBuilderPostgres(unittest.TestCase, TestBuilderBase): def test_adding_tabs_in_from(self): From 479f637f7695e361ea1632114bbd71bf698bf6bd Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 10 Jan 2022 20:52:28 +0530 Subject: [PATCH 016/151] fix(single:set_value): Cast using sbool instead of str for all --- frappe/database/database.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index 28ae177885..866814c094 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -21,7 +21,7 @@ from frappe import _ from frappe.model.utils.link_count import flush_local_link_count from frappe.query_builder.functions import Count from frappe.query_builder.utils import DocType -from frappe.utils import cast, get_datetime, getdate, now +from frappe.utils import cast, get_datetime, getdate, now, sbool from .query import Query @@ -692,7 +692,7 @@ class Database(object): filters={"field": ("in", tuple(to_update)), "doctype": dt}, debug=debug ) - singles_data = ((dt, key, str(value) if value else value) for key, value in to_update.items()) + singles_data = ((dt, key, sbool(value)) for key, value in to_update.items()) query = ( frappe.qb.into("Singles") .columns("doctype", "field", "value") From 202145f0ec558a06b64c31959237809a09929b38 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 10 Jan 2022 20:53:25 +0530 Subject: [PATCH 017/151] test: Add tests for frappe.db.set_value --- frappe/tests/test_db.py | 165 ++++++++++++++++++++++++++++++++++------ 1 file changed, 141 insertions(+), 24 deletions(-) diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index cdef4354ed..679ff73728 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -1,21 +1,21 @@ -# -*- coding: utf-8 -*- - -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import datetime +import inspect import unittest from random import choice -import datetime +from unittest.mock import patch import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_field -from frappe.utils import random_string -from frappe.utils.testutils import clear_custom_fields -from frappe.query_builder import Field from frappe.database import savepoint - -from .test_query_builder import run_only_if, db_type_is +from frappe.database.database import Database +from frappe.query_builder import Field from frappe.query_builder.functions import Concat_ws +from frappe.tests.test_query_builder import db_type_is, run_only_if +from frappe.utils import add_days, now, random_string +from frappe.utils.testutils import clear_custom_fields class TestDB(unittest.TestCase): @@ -84,20 +84,6 @@ class TestDB(unittest.TestCase): ), ) - def test_set_value(self): - todo1 = frappe.get_doc(dict(doctype='ToDo', description = 'test_set_value 1')).insert() - todo2 = frappe.get_doc(dict(doctype='ToDo', description = 'test_set_value 2')).insert() - - frappe.db.set_value('ToDo', todo1.name, 'description', 'test_set_value change 1') - self.assertEqual(frappe.db.get_value('ToDo', todo1.name, 'description'), 'test_set_value change 1') - - # multiple set-value - frappe.db.set_value('ToDo', dict(description=('like', '%test_set_value%')), - 'description', 'change 2') - - self.assertEqual(frappe.db.get_value('ToDo', todo1.name, 'description'), 'change 2') - self.assertEqual(frappe.db.get_value('ToDo', todo2.name, 'description'), 'change 2') - def test_escape(self): frappe.db.escape("香港濟生堂製藥有限公司 - IT".encode("utf-8")) @@ -246,7 +232,6 @@ class TestDB(unittest.TestCase): frappe.delete_doc(test_doctype, doc) clear_custom_fields(test_doctype) - def test_savepoints(self): frappe.db.rollback() save_point = "todonope" @@ -353,6 +338,138 @@ class TestDDLCommandsMaria(unittest.TestCase): self.assertEquals(len(indexs_in_table), 2) +class TestDBSetValue(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.todo1 = frappe.get_doc(doctype="ToDo", description="test_set_value 1").insert() + cls.todo2 = frappe.get_doc(doctype="ToDo", description="test_set_value 2").insert() + + def test_update_single_doctype_field(self): + value = frappe.db.get_single_value("System Settings", "deny_multiple_sessions") + changed_value = not value + + frappe.db.set_value("System Settings", "System Settings", "deny_multiple_sessions", changed_value) + current_value = frappe.db.get_single_value("System Settings", "deny_multiple_sessions") + self.assertEqual(current_value, changed_value) + + changed_value = not current_value + frappe.db.set_value("System Settings", None, "deny_multiple_sessions", changed_value) + current_value = frappe.db.get_single_value("System Settings", "deny_multiple_sessions") + self.assertEqual(current_value, changed_value) + + changed_value = not current_value + frappe.db.set_single_value("System Settings", "deny_multiple_sessions", changed_value) + current_value = frappe.db.get_single_value("System Settings", "deny_multiple_sessions") + self.assertEqual(current_value, changed_value) + + def test_update_single_row_single_column(self): + frappe.db.set_value("ToDo", self.todo1.name, "description", "test_set_value change 1") + updated_value = frappe.db.get_value("ToDo", self.todo1.name, "description") + self.assertEqual(updated_value, "test_set_value change 1") + + def test_update_single_row_multiple_columns(self): + description, status = "Upated by test_update_single_row_multiple_columns", "Closed" + + frappe.db.set_value("ToDo", self.todo1.name, { + "description": description, + "status": status, + }, update_modified=False) + + updated_desciption, updated_status = frappe.db.get_value("ToDo", + filters={"name": self.todo1.name}, + fieldname=["description", "status"] + ) + + self.assertEqual(description, updated_desciption) + self.assertEqual(status, updated_status) + + def test_update_multiple_rows_single_column(self): + frappe.db.set_value("ToDo", {"description": ("like", "%test_set_value%")}, "description", "change 2") + + self.assertEqual(frappe.db.get_value("ToDo", self.todo1.name, "description"), "change 2") + self.assertEqual(frappe.db.get_value("ToDo", self.todo2.name, "description"), "change 2") + + def test_update_multiple_rows_multiple_columns(self): + todos_to_update = frappe.get_all("ToDo", filters={ + "description": ("like", "%test_set_value%"), + "status": ("!=", "Closed") + }, pluck="name") + + frappe.db.set_value("ToDo", { + "description": ("like", "%test_set_value%"), + "status": ("!=", "Closed") + }, { + "status": "Closed", + "priority": "High" + }) + + test_result = frappe.get_all("ToDo", filters={"name": ("in", todos_to_update)}, fields=["status", "priority"]) + + self.assertTrue(all(x for x in test_result if x["status"] == "Closed")) + self.assertTrue(all(x for x in test_result if x["priority"] == "High")) + + def test_update_modified_options(self): + self.todo2.reload() + + todo = self.todo2 + updated_description = f"{todo.description} - by `test_update_modified_options`" + custom_modified = datetime.datetime.fromisoformat(add_days(now(), 10)) + custom_modified_by = "user_that_doesnt_exist@example.com" + + frappe.db.set_value("ToDo", todo.name, "description", updated_description, update_modified=False) + self.assertEqual(updated_description, frappe.db.get_value("ToDo", todo.name, "description")) + self.assertEqual(todo.modified, frappe.db.get_value("ToDo", todo.name, "modified")) + + frappe.db.set_value("ToDo", todo.name, "description", "test_set_value change 1", modified=custom_modified, modified_by=custom_modified_by) + self.assertTupleEqual( + (custom_modified, custom_modified_by), + frappe.db.get_value("ToDo", todo.name, ["modified", "modified_by"]) + ) + + def test_for_update(self): + self.todo1.reload() + + with patch.object(Database, "sql") as sql_called: + frappe.db.set_value( + self.todo1.doctype, + self.todo1.name, + "description", + f"{self.todo1.description}-edit by `test_for_update`" + ) + first_query = sql_called.call_args_list[0].args[0] + second_query = sql_called.call_args_list[1].args[0] + self.assertTrue(sql_called.call_count == 2) + self.assertTrue("FOR UPDATE" in first_query) + self.assertTrue("UPDATE `tabToDo` SET" in second_query) + + def test_cleared_cache(self): + self.todo2.reload() + + with patch.object(frappe, "clear_document_cache") as clear_cache: + frappe.db.set_value( + self.todo2.doctype, + self.todo2.name, + "description", + f"{self.todo2.description}-edit by `test_cleared_cache`" + ) + clear_cache.assert_called() + + def test_update_alias(self): + args = (self.todo1.doctype, self.todo1.name, "description", "Updated by `test_update_alias`") + kwargs = {"for_update": False, "modified": None, "modified_by": None, "update_modified": True, "debug": False} + + self.assertTrue("return self.set_value(" in inspect.getsource(frappe.db.update)) + + with patch.object(Database, "set_value") as set_value: + frappe.db.update(*args, **kwargs) + set_value.assert_called_once() + set_value.assert_called_with(*args, **kwargs) + + @classmethod + def tearDownClass(cls): + frappe.db.rollback() + + @run_only_if(db_type_is.POSTGRES) class TestDDLCommandsPost(unittest.TestCase): test_table_name = "TestNotes" From d81fe8699c4adfc06864dc3231c50af8b074058f Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 10 Jan 2022 20:53:54 +0530 Subject: [PATCH 018/151] feat: frappe.db.set_single_value Alias to frappe.db.set_value without having to pass the doctype name twice or teh second one as None Other changes: * set/get db APIs for single DocTypes * Make use of redundant cache key in frappe.db.get_single_value --- frappe/client.py | 1 - frappe/database/database.py | 16 +++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/frappe/client.py b/frappe/client.py index e835e7fee7..7280c29ba4 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -99,7 +99,6 @@ def get_value(doctype, fieldname, filters=None, as_dict=True, debug=False, paren if not filters: filters = None - if frappe.get_meta(doctype).issingle: value = frappe.db.get_values_from_single(fields, filters, doctype, as_dict=as_dict, debug=debug) else: diff --git a/frappe/database/database.py b/frappe/database/database.py index 866814c094..0a02380e05 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -556,6 +556,20 @@ class Database(object): def get_list(*args, **kwargs): return frappe.get_list(*args, **kwargs) + def set_single_value(self, doctype, fieldname, value, *args, **kwargs): + """Set field value of Single DocType. + + :param doctype: DocType of the single object + :param fieldname: `fieldname` of the property + :param value: `value` of the property + + Example: + + # Update the `deny_multiple_sessions` field in System Settings DocType. + company = frappe.db.set_single_value("System Settings", "deny_multiple_sessions", True) + """ + return self.set_value(doctype, doctype, fieldname, value, *args, **kwargs) + def get_single_value(self, doctype, fieldname, cache=False): """Get property of Single DocType. Cache locally by default @@ -571,7 +585,7 @@ class Database(object): if not doctype in self.value_cache: self.value_cache[doctype] = {} - if fieldname in self.value_cache[doctype]: + if cache and fieldname in self.value_cache[doctype]: return self.value_cache[doctype][fieldname] val = self.query.get_sql( From 15f3523c242200e4a4a914db4c68c82ea9602a7f Mon Sep 17 00:00:00 2001 From: saxenabhishek Date: Tue, 11 Jan 2022 00:04:14 +0530 Subject: [PATCH 019/151] test: qb parameterization where conditions set update conditions where with a function --- frappe/tests/test_query_builder.py | 37 ++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/frappe/tests/test_query_builder.py b/frappe/tests/test_query_builder.py index ab1d8292b8..bc98e166ea 100644 --- a/frappe/tests/test_query_builder.py +++ b/frappe/tests/test_query_builder.py @@ -66,24 +66,47 @@ class TestBuilderBase(object): self.assertIsInstance(query.run, Callable) self.assertIsInstance(data, list) - def test_walk(self): + +class TestParameterization(unittest.TestCase): + def test_where_conditions(self): DocType = frappe.qb.DocType("DocType") query = ( frappe.qb.from_(DocType) .select(DocType.name) - .where( - (DocType.owner == "Administrator' --") - & (Coalesce(DocType.search_fields == "subject")) - ) + .where((DocType.owner == "Administrator' --")) ) self.assertTrue("walk" in dir(query)) query, params = query.walk() self.assertIn("%(param1)s", query) - self.assertIn("%(param2)s", query) self.assertIn("param1", params) self.assertEqual(params["param1"], "Administrator' --") - self.assertEqual(params["param2"], "subject") + + def test_set_cnoditions(self): + DocType = frappe.qb.DocType("DocType") + query = frappe.qb.update(DocType).set(DocType.value, "some_value") + + self.assertTrue("walk" in dir(query)) + query, params = query.walk() + + self.assertIn("%(param1)s", query) + self.assertIn("param1", params) + self.assertEqual(params["param1"], "some_value") + + def test_where_conditions_functions(self): + DocType = frappe.qb.DocType("DocType") + query = ( + frappe.qb.from_(DocType) + .select(DocType.name) + .where(Coalesce(DocType.search_fields == "subject")) + ) + + self.assertTrue("walk" in dir(query)) + query, params = query.walk() + + self.assertIn("%(param1)s", query) + self.assertIn("param1", params) + self.assertEqual(params["param1"], "subject") @run_only_if(db_type_is.MARIADB) From 0f7539472045f6f0111411592d6fdbcfe5bd94de Mon Sep 17 00:00:00 2001 From: saxenabhishek Date: Tue, 11 Jan 2022 00:05:38 +0530 Subject: [PATCH 020/151] refactor: pythonic NamedParameterWrapper --- frappe/query_builder/terms.py | 13 ++++++++----- frappe/query_builder/utils.py | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/frappe/query_builder/terms.py b/frappe/query_builder/terms.py index 0e0e2c4800..21cc557730 100644 --- a/frappe/query_builder/terms.py +++ b/frappe/query_builder/terms.py @@ -5,18 +5,21 @@ from pypika.utils import format_alias_sql class NamedParameterWrapper(): - def __init__(self, parameters: Dict[str, Any]): - self.parameters = parameters + def __init__(self) -> None: + self.parameters={} - def update_parameters(self, param_key: Any, param_value: Any, **kwargs): + def update_parameters(self, param_key: Any, param_value: Any, **kwargs)->None: self.parameters[param_key[2:-2]] = param_value - def get_sql(self, **kwargs): + def get_sql(self, **kwargs)->str: return f'%(param{len(self.parameters) + 1})s' + def get_parameters(self)->Dict[str, Any]: + return self.parameters + class ParameterizedValueWrapper(ValueWrapper): - def get_sql(self, quote_char: Optional[str] = None, secondary_quote_char: str = "'", param_wrapper= None, **kwargs: Any) -> str: + def get_sql(self, quote_char: Optional[str] = None, secondary_quote_char: str = "'", param_wrapper:NamedParameterWrapper = None, **kwargs: Any) -> str: if param_wrapper is None: sql = self.get_value_sql(quote_char=quote_char, secondary_quote_char=secondary_quote_char, **kwargs) return format_alias_sql(sql, self.alias, quote_char=quote_char, **kwargs) diff --git a/frappe/query_builder/utils.py b/frappe/query_builder/utils.py index 7797ce856c..cbd6147e01 100644 --- a/frappe/query_builder/utils.py +++ b/frappe/query_builder/utils.py @@ -59,11 +59,11 @@ def patch_query_execute(): return frappe.db.sql(query, params, *args, **kwargs) # nosemgrep def prepare_query(query): - params = {} - query = query.get_sql(param_wrapper=NamedParameterWrapper(params)) + param_collector = NamedParameterWrapper() + query = query.get_sql(param_wrapper=param_collector) if frappe.flags.in_safe_exec and not query.lower().strip().startswith("select"): raise frappe.PermissionError('Only SELECT SQL allowed in scripting') - return query, params + return query, param_collector.get_parameters() query_class = get_attr(str(frappe.qb).split("'")[1]) builder_class = get_type_hints(query_class._builder).get('return') From e2d49c17fffde5330f98a96ce11132d7f81a56b5 Mon Sep 17 00:00:00 2001 From: saxenabhishek Date: Tue, 11 Jan 2022 00:43:59 +0530 Subject: [PATCH 021/151] refactor: remove reduandant func call --- frappe/query_builder/terms.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/frappe/query_builder/terms.py b/frappe/query_builder/terms.py index 21cc557730..9cf2b08cf0 100644 --- a/frappe/query_builder/terms.py +++ b/frappe/query_builder/terms.py @@ -8,11 +8,10 @@ class NamedParameterWrapper(): def __init__(self) -> None: self.parameters={} - def update_parameters(self, param_key: Any, param_value: Any, **kwargs)->None: + def get_sql(self, param_value , **kwargs)->str: + param_key = f'%(param{len(self.parameters) + 1})s' self.parameters[param_key[2:-2]] = param_value - - def get_sql(self, **kwargs)->str: - return f'%(param{len(self.parameters) + 1})s' + return param_key def get_parameters(self)->Dict[str, Any]: return self.parameters @@ -24,12 +23,10 @@ class ParameterizedValueWrapper(ValueWrapper): sql = self.get_value_sql(quote_char=quote_char, secondary_quote_char=secondary_quote_char, **kwargs) return format_alias_sql(sql, self.alias, quote_char=quote_char, **kwargs) else: + value_sql = self.value if isinstance(self.value, str): value_sql = self.get_value_sql(quote_char=quote_char, **kwargs) - else: - value_sql = self.value - param_sql = param_wrapper.get_sql(**kwargs) - param_wrapper.update_parameters(param_key=param_sql, param_value=value_sql, **kwargs) + param_sql = param_wrapper.get_sql(param_value=value_sql, **kwargs) return format_alias_sql(param_sql, self.alias, quote_char=quote_char, **kwargs) From 471c0bd697404080e4c6df841ce2db02b88880d1 Mon Sep 17 00:00:00 2001 From: saxenabhishek Date: Tue, 11 Jan 2022 01:00:02 +0530 Subject: [PATCH 022/151] docs: Working of NamedParameters in qb --- frappe/query_builder/terms.py | 62 ++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/frappe/query_builder/terms.py b/frappe/query_builder/terms.py index 9cf2b08cf0..b0be40e0d2 100644 --- a/frappe/query_builder/terms.py +++ b/frappe/query_builder/terms.py @@ -4,33 +4,64 @@ from pypika.terms import Function, ValueWrapper from pypika.utils import format_alias_sql -class NamedParameterWrapper(): - def __init__(self) -> None: - self.parameters={} +class NamedParameterWrapper: + """Utility class to hold parameter values and keys""" - def get_sql(self, param_value , **kwargs)->str: - param_key = f'%(param{len(self.parameters) + 1})s' + def __init__(self) -> None: + self.parameters = {} + + def get_sql(self, param_value: Any, **kwargs) -> str: + """returns SQL for a parameter, while adding the real value in a dict + + Args: + param_value (Any): Value of the parameter + + Returns: + str: parameter used in the SQL query + """ + param_key = f"%(param{len(self.parameters) + 1})s" self.parameters[param_key[2:-2]] = param_value return param_key - def get_parameters(self)->Dict[str, Any]: + def get_parameters(self) -> Dict[str, Any]: + """get dict with parameters and values + + Returns: + Dict[str, Any]: parameter dict + """ return self.parameters class ParameterizedValueWrapper(ValueWrapper): - def get_sql(self, quote_char: Optional[str] = None, secondary_quote_char: str = "'", param_wrapper:NamedParameterWrapper = None, **kwargs: Any) -> str: + """ + Class to monkey patch ValueWrapper + + Adds functionality to parameterize queries when a `param wrapper` is passed in get_sql() + """ + + def get_sql(self, quote_char: Optional[str] = None, secondary_quote_char: str = "'", param_wrapper: Optional[NamedParameterWrapper] = None, **kwargs: Any) -> str: if param_wrapper is None: - sql = self.get_value_sql(quote_char=quote_char, secondary_quote_char=secondary_quote_char, **kwargs) + sql = self.get_value_sql( + quote_char=quote_char, + secondary_quote_char=secondary_quote_char, + **kwargs, + ) return format_alias_sql(sql, self.alias, quote_char=quote_char, **kwargs) else: value_sql = self.value if isinstance(self.value, str): + # add quotes if it's a string value value_sql = self.get_value_sql(quote_char=quote_char, **kwargs) param_sql = param_wrapper.get_sql(param_value=value_sql, **kwargs) return format_alias_sql(param_sql, self.alias, quote_char=quote_char, **kwargs) class ParameterizedFunction(Function): + """ + Class to monkey patch pypika.terms.Functions + + Only to pass `param_wrapper` in `get_function_sql`. + """ def get_sql(self, **kwargs: Any) -> str: with_alias = kwargs.pop("with_alias", False) with_namespace = kwargs.pop("with_namespace", False) @@ -38,15 +69,24 @@ class ParameterizedFunction(Function): dialect = kwargs.pop("dialect", None) param_wrapper = kwargs.pop("param_wrapper", None) - function_sql = self.get_function_sql(with_namespace=with_namespace, quote_char=quote_char, param_wrapper=param_wrapper, dialect=dialect) + function_sql = self.get_function_sql( + with_namespace=with_namespace, + quote_char=quote_char, + param_wrapper=param_wrapper, + dialect=dialect, + ) if self.schema is not None: function_sql = "{schema}.{function}".format( - schema=self.schema.get_sql(quote_char=quote_char, dialect=dialect, **kwargs), + schema=self.schema.get_sql( + quote_char=quote_char, dialect=dialect, **kwargs + ), function=function_sql, ) if with_alias: - return format_alias_sql(function_sql, self.alias, quote_char=quote_char, **kwargs) + return format_alias_sql( + function_sql, self.alias, quote_char=quote_char, **kwargs + ) return function_sql From 9f1503c5821c0601e1845248c6357c6682cedb9e Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 11 Jan 2022 12:54:18 +0530 Subject: [PATCH 023/151] test: Postgres compatible db tests --- frappe/tests/test_db.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index 679ff73728..6998468bfe 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -438,9 +438,14 @@ class TestDBSetValue(unittest.TestCase): ) first_query = sql_called.call_args_list[0].args[0] second_query = sql_called.call_args_list[1].args[0] + self.assertTrue(sql_called.call_count == 2) self.assertTrue("FOR UPDATE" in first_query) - self.assertTrue("UPDATE `tabToDo` SET" in second_query) + if frappe.conf.db_type == "postgres": + from frappe.database.postgres.database import modify_query + self.assertTrue(modify_query("UPDATE `tabToDo` SET") in second_query) + if frappe.conf.db_type == "mariadb": + self.assertTrue("UPDATE `tabToDo` SET" in second_query) def test_cleared_cache(self): self.todo2.reload() From f5072af00da03c9fe7aca71e0c98b282fff54c8f Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 11 Jan 2022 12:54:48 +0530 Subject: [PATCH 024/151] fix: sbool before cint in cast for Check types Noticed an issue when get_single_value wasn't returning the correct values; by converting true to 0. Tested it out. Here's the examples: In [2]: sbool("2") Out[2]: '2' In [3]: cint(sbool("2")) Out[3]: 2 In [4]: cint(sbool("-1")) Out[4]: -1 In [5]: cint(sbool("0")) Out[5]: 0 In [6]: cint(sbool("1000")) Out[6]: 1000 In [7]: cint(sbool("10_000")) Out[7]: 10000 In [8]: cint(sbool("true")) Out[8]: 1 --- frappe/utils/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 545d49054a..313816557d 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -607,7 +607,7 @@ def cast(fieldtype, value=None): value = flt(value) elif fieldtype in ("Int", "Check"): - value = cint(value) + value = cint(sbool(value)) elif fieldtype in ("Data", "Text", "Small Text", "Long Text", "Text Editor", "Select", "Link", "Dynamic Link"): From ece1564e4d05ebc225bdc039513e449085780fc9 Mon Sep 17 00:00:00 2001 From: saxenabhishek Date: Tue, 11 Jan 2022 15:32:07 +0530 Subject: [PATCH 025/151] fix: replace the field method with a column --- frappe/query_builder/__init__.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/frappe/query_builder/__init__.py b/frappe/query_builder/__init__.py index bf7be84c51..a49c617ecf 100644 --- a/frappe/query_builder/__init__.py +++ b/frappe/query_builder/__init__.py @@ -1,8 +1,18 @@ -from frappe.query_builder.terms import ParameterizedValueWrapper, ParameterizedFunction -import pypika +import pypika.terms +from pypika import * + +from frappe.query_builder.terms import ParameterizedFunction, ParameterizedValueWrapper +from frappe.query_builder.utils import ( + Column, + DocType, + get_query_builder, + patch_query_aggregation, + patch_query_execute, +) pypika.terms.ValueWrapper = ParameterizedValueWrapper pypika.terms.Function = ParameterizedFunction -from pypika import * -from frappe.query_builder.utils import Column, DocType, get_query_builder, patch_query_execute, patch_query_aggregation +# * Overrides the field() method and replaces it with the a `PseudoColumn` 'field' for consistency +pypika.queries.Selectable.__getattr__ = lambda table, x: Field(x, table=table) +pypika.queries.Selectable.field = pypika.terms.PseudoColumn("field") From b64dc6508801aea8bb40f02190807de59c35b7fc Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 11 Jan 2022 16:25:10 +0530 Subject: [PATCH 026/151] fix: Set cache=True to maintain old behaviour consistency --- frappe/database/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index 0a02380e05..3b6d2d47b5 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -570,7 +570,7 @@ class Database(object): """ return self.set_value(doctype, doctype, fieldname, value, *args, **kwargs) - def get_single_value(self, doctype, fieldname, cache=False): + def get_single_value(self, doctype, fieldname, cache=True): """Get property of Single DocType. Cache locally by default :param doctype: DocType of the single object whose value is requested From 9121014f488aa82a0fd38222ad2f85ea80ba73c9 Mon Sep 17 00:00:00 2001 From: saxenabhishek Date: Tue, 11 Jan 2022 16:53:42 +0530 Subject: [PATCH 027/151] fix: ignore copy of getattr methods --- frappe/query_builder/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/query_builder/__init__.py b/frappe/query_builder/__init__.py index a49c617ecf..5b58e70c4e 100644 --- a/frappe/query_builder/__init__.py +++ b/frappe/query_builder/__init__.py @@ -1,5 +1,7 @@ import pypika.terms from pypika import * +from pypika import Field +from pypika.utils import ignore_copy from frappe.query_builder.terms import ParameterizedFunction, ParameterizedValueWrapper from frappe.query_builder.utils import ( @@ -14,5 +16,6 @@ pypika.terms.ValueWrapper = ParameterizedValueWrapper pypika.terms.Function = ParameterizedFunction # * Overrides the field() method and replaces it with the a `PseudoColumn` 'field' for consistency -pypika.queries.Selectable.__getattr__ = lambda table, x: Field(x, table=table) +pypika.queries.Selectable.__getattr__ = ignore_copy(lambda table, x: Field(x, table=table)) +pypika.queries.Selectable.__getitem__ = ignore_copy(lambda table, x: Field(x, table=table)) pypika.queries.Selectable.field = pypika.terms.PseudoColumn("field") From 02bdc35490fff1e2b0c31c1ac08bcf0151873807 Mon Sep 17 00:00:00 2001 From: saxenabhishek Date: Sat, 15 Jan 2022 14:55:28 +0530 Subject: [PATCH 028/151] fix: make parameters for strings only --- frappe/query_builder/terms.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/frappe/query_builder/terms.py b/frappe/query_builder/terms.py index b0be40e0d2..71f60af656 100644 --- a/frappe/query_builder/terms.py +++ b/frappe/query_builder/terms.py @@ -39,21 +39,24 @@ class ParameterizedValueWrapper(ValueWrapper): Adds functionality to parameterize queries when a `param wrapper` is passed in get_sql() """ - def get_sql(self, quote_char: Optional[str] = None, secondary_quote_char: str = "'", param_wrapper: Optional[NamedParameterWrapper] = None, **kwargs: Any) -> str: - if param_wrapper is None: + def get_sql( + self, + quote_char: Optional[str] = None, + secondary_quote_char: str = "'", + param_wrapper: Optional[NamedParameterWrapper] = None, + **kwargs: Any, + ) -> str: + if param_wrapper and isinstance(self.value, str): + # add quotes if it's a string value + value_sql = self.get_value_sql(quote_char=quote_char, **kwargs) + sql = param_wrapper.get_sql(param_value=value_sql, **kwargs) + else: sql = self.get_value_sql( quote_char=quote_char, secondary_quote_char=secondary_quote_char, **kwargs, ) - return format_alias_sql(sql, self.alias, quote_char=quote_char, **kwargs) - else: - value_sql = self.value - if isinstance(self.value, str): - # add quotes if it's a string value - value_sql = self.get_value_sql(quote_char=quote_char, **kwargs) - param_sql = param_wrapper.get_sql(param_value=value_sql, **kwargs) - return format_alias_sql(param_sql, self.alias, quote_char=quote_char, **kwargs) + return format_alias_sql(sql, self.alias, quote_char=quote_char, **kwargs) class ParameterizedFunction(Function): @@ -62,6 +65,7 @@ class ParameterizedFunction(Function): Only to pass `param_wrapper` in `get_function_sql`. """ + def get_sql(self, **kwargs: Any) -> str: with_alias = kwargs.pop("with_alias", False) with_namespace = kwargs.pop("with_namespace", False) From b403d67845770c187d8b6772441e1903d6e04677 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 17 Jan 2022 14:53:35 +0530 Subject: [PATCH 029/151] fix!: Don't use strtobool in sbool util strtobool would convert 't', 'yes' to True too. Which shouldn't be a problem generally but could be one possibly. --- frappe/utils/data.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 59ce135b90..764df27187 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -737,12 +737,15 @@ def sbool(x): x (str): String to be converted to Bool Returns: - object: Returns Boolean or type(x) + object: Returns Boolean or x """ - from distutils.util import strtobool - try: - return bool(strtobool(x)) + val = x.lower() + if val in ('true', '1'): + return True + elif val in ('false', '0'): + return False + return bool(x) except Exception: return x From 429f839ea3a79b846ce17a85c7ab4a3c798f5181 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 17 Jan 2022 15:07:44 +0530 Subject: [PATCH 030/151] style: Add typing, sorted imports# --- frappe/__init__.py | 1 - frappe/utils/data.py | 61 ++++++++++++++++++++++++++------------------ 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index f93fa538bb..86e5a03359 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -314,7 +314,6 @@ def destroy(): release_local(local) -# memcache redis_server = None def cache() -> "RedisWrapper": """Returns redis connection.""" diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 764df27187..8148d194c6 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -1,17 +1,22 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -from typing import Optional -import frappe -import operator -import json import base64 -import re, datetime, math, time +import datetime +import json +import math +import operator +import re +import time from code import compile_command -from urllib.parse import quote, urljoin -from frappe.desk.utils import slug -from click import secho from enum import Enum +from typing import Any, Dict, List, Optional, Tuple, Union +from urllib.parse import quote, urljoin + +from click import secho + +import frappe +from frappe.desk.utils import slug DATE_FORMAT = "%Y-%m-%d" TIME_FORMAT = "%H:%M:%S.%f" @@ -201,7 +206,7 @@ def get_time_zone(): return frappe.cache().get_value("time_zone", _get_time_zone) def convert_utc_to_timezone(utc_timestamp, time_zone): - from pytz import timezone, UnknownTimeZoneError + from pytz import UnknownTimeZoneError, timezone utcnow = timezone('UTC').localize(utc_timestamp) try: return utcnow.astimezone(timezone(time_zone)) @@ -726,7 +731,7 @@ def ceil(s): def cstr(s, encoding='utf-8'): return frappe.as_unicode(s, encoding) -def sbool(x): +def sbool(x: str) -> Union[bool, Any]: """Converts str object to Boolean if possible. Example: "true" becomes True @@ -920,13 +925,13 @@ number_format_info = { "#.########": (".", "", 8) } -def get_number_format_info(format): +def get_number_format_info(format: str) -> Tuple[str, str, int]: return number_format_info.get(format) or (".", ",", 2) # # convert currency to words # -def money_in_words(number, main_currency = None, fraction_currency=None): +def money_in_words(number: str, main_currency: Optional[str] = None, fraction_currency: Optional[str] = None): """ Returns string in words with currency and fraction currency. """ @@ -1012,9 +1017,11 @@ def is_image(filepath): def get_thumbnail_base64_for_image(src): from os.path import exists as file_exists + from PIL import Image + + from frappe import cache, safe_decode from frappe.core.doctype.file.file import get_local_image - from frappe import safe_decode, cache if not src: frappe.throw('Invalid source for image: {0}'.format(src)) @@ -1305,7 +1312,7 @@ operator_map = { "None": lambda a, b: (not a) and True or False } -def evaluate_filters(doc, filters): +def evaluate_filters(doc, filters: Union[Dict, List, Tuple]): '''Returns true if doc matches filters''' if isinstance(filters, dict): for key, value in filters.items(): @@ -1322,7 +1329,7 @@ def evaluate_filters(doc, filters): return True -def compare(val1, condition, val2, fieldtype=None): +def compare(val1: Any, condition: str, val2: Any, fieldtype: Optional[str] = None): ret = False if fieldtype: val2 = cast(fieldtype, val2) @@ -1331,7 +1338,7 @@ def compare(val1, condition, val2, fieldtype=None): return ret -def get_filter(doctype, f, filters_config=None): +def get_filter(doctype: str, f: Union[Dict, List, Tuple], filters_config=None) -> "frappe._dict": """Returns a _dict like { @@ -1418,8 +1425,10 @@ def make_filter_dict(filters): return _filter def sanitize_column(column_name): - from frappe import _ import sqlparse + + from frappe import _ + regex = re.compile("^.*[,'();].*") column_name = sqlparse.format(column_name, strip_comments=True, keyword_case="lower") blacklisted_keywords = ['select', 'create', 'insert', 'delete', 'drop', 'update', 'case', 'and', 'or'] @@ -1495,9 +1504,10 @@ def strip(val, chars=None): return (val or "").replace("\ufeff", "").replace("\u200b", "").strip(chars) def to_markdown(html): - from html2text import html2text from html.parser import HTMLParser + from html2text import html2text + text = None try: text = html2text(html or '') @@ -1507,7 +1517,8 @@ def to_markdown(html): return text def md_to_html(markdown_text): - from markdown2 import markdown as _markdown, MarkdownError + from markdown2 import MarkdownError + from markdown2 import markdown as _markdown extras = { 'fenced-code-blocks': None, @@ -1532,14 +1543,14 @@ def md_to_html(markdown_text): def markdown(markdown_text): return md_to_html(markdown_text) -def is_subset(list_a, list_b): +def is_subset(list_a: List, list_b: List) -> bool: '''Returns whether list_a is a subset of list_b''' return len(list(set(list_a) & set(list_b))) == len(list_a) -def generate_hash(*args, **kwargs): +def generate_hash(*args, **kwargs) -> str: return frappe.generate_hash(*args, **kwargs) -def guess_date_format(date_string): +def guess_date_format(date_string: str) -> str: DATE_FORMATS = [ r"%d/%b/%y", r"%d-%m-%Y", @@ -1614,13 +1625,13 @@ def guess_date_format(date_string): if date_format and time_format: return (date_format + ' ' + time_format).strip() -def validate_json_string(string): +def validate_json_string(string: str) -> None: try: json.loads(string) except (TypeError, ValueError): raise frappe.ValidationError -def get_user_info_for_avatar(user_id): +def get_user_info_for_avatar(user_id: str) -> Dict: user_info = { "email": user_id, "image": "", From 8619235774a20ac98278030fa4e990cddf01abed Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 19 Jan 2022 16:46:53 +0530 Subject: [PATCH 031/151] fix: Fetch file extension from Response --- frappe/core/doctype/file/file.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index adf10b9a03..4793270b39 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -650,9 +650,15 @@ def setup_folder_path(filename, new_parent): from frappe.model.rename_doc import rename_doc rename_doc("File", file.name, file.get_name_based_on_parent_folder(), ignore_permissions=True) -def get_extension(filename, extn, content): +def get_extension(filename, extn, content: bytes = None, response: "Response" = None) -> str: mimetype = None + if response: + content_type = response.headers.get("Content-Type") + + if content_type: + return mimetypes.guess_extension(content_type)[1:] + if extn: # remove '?' char and parameters from extn if present if '?' in extn: @@ -721,7 +727,7 @@ def get_web_image(file_url): filename = get_random_filename() extn = None - extn = get_extension(filename, extn, r.content) + extn = get_extension(filename, extn, response=r) filename = "/files/" + strip(unquote(filename)) return image, filename, extn From f79f49afb56d9103ee7911ac3ed8484427bf2752 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 19 Jan 2022 16:48:50 +0530 Subject: [PATCH 032/151] chore: Add typing, use better supported APIs --- frappe/core/doctype/file/file.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 4793270b39..9686f9f592 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -17,9 +17,10 @@ import os import re import shutil import zipfile +from typing import TYPE_CHECKING, Tuple import requests -import requests.exceptions +from requests.exceptions import HTTPError, SSLError from PIL import Image, ImageFile, ImageOps from io import BytesIO from urllib.parse import quote, unquote @@ -31,6 +32,11 @@ from frappe.utils import call_hook_method, cint, cstr, encode, get_files_path, g from frappe.utils.image import strip_exif_data, optimize_image from frappe.utils.file_manager import safe_b64decode +if TYPE_CHECKING: + from PIL.ImageFile import ImageFile + from requests.models import Response + + class MaxFileSizeReachedError(frappe.ValidationError): pass @@ -276,7 +282,7 @@ class File(Document): image, filename, extn = get_local_image(self.file_url) else: image, filename, extn = get_web_image(self.file_url) - except (requests.exceptions.HTTPError, requests.exceptions.SSLError, IOError, TypeError): + except (HTTPError, SSLError, IOError, TypeError): return size = width, height @@ -701,14 +707,14 @@ def get_local_image(file_url): return image, filename, extn -def get_web_image(file_url): +def get_web_image(file_url: str) -> Tuple["ImageFile", str, str]: # download file_url = frappe.utils.get_url(file_url) r = requests.get(file_url, stream=True) try: r.raise_for_status() - except requests.exceptions.HTTPError as e: - if "404" in e.args[0]: + except HTTPError: + if r.status_code == 404: frappe.msgprint(_("File '{0}' not found").format(file_url)) else: frappe.msgprint(_("Unable to read file format for {0}").format(file_url)) From 1a8e773127b2bdb192330cc9074fdea464aacdad Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 19 Jan 2022 19:28:22 +0530 Subject: [PATCH 033/151] fix: if response content-type is bin, try guessing downloaded content type --- frappe/core/doctype/file/file.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 9686f9f592..67fd678dd7 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -734,6 +734,9 @@ def get_web_image(file_url: str) -> Tuple["ImageFile", str, str]: extn = None extn = get_extension(filename, extn, response=r) + if extn == "bin": + extn = get_extension(filename, extn, content=r.content) or "png" + filename = "/files/" + strip(unquote(filename)) return image, filename, extn From 33ba44136860896e25dcfeac77d96a0b5746c422 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 19 Jan 2022 19:29:30 +0530 Subject: [PATCH 034/151] test: Add test for thumbnail creation of image without extn --- frappe/core/doctype/file/test_file.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index 2c1042e104..e415772d9f 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -1,15 +1,15 @@ -# -*- coding: utf-8 -*- -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import base64 import json import frappe import os import unittest +from unittest.mock import patch + from frappe import _ -from frappe.core.doctype.file.file import get_attached_images, move_file, get_files_in_folder, unzip_file +from frappe.core.doctype.file.file import get_attached_images, get_web_image, move_file, get_files_in_folder, unzip_file from frappe.utils import get_files_path -# test_records = frappe.get_test_records('File') test_content1 = 'Hello' test_content2 = 'Hello World' @@ -407,6 +407,16 @@ class TestFile(unittest.TestCase): test_file.make_thumbnail() self.assertEquals(test_file.thumbnail_url, '/files/image_small.jpg') + # test web image without extension + test_file = frappe.get_doc({ + "doctype": "File", + "file_name": 'logo', + "file_url": frappe.utils.get_url('/_test/assets/image'), + }).insert(ignore_permissions=True) + + test_file.make_thumbnail() + self.assertTrue(test_file.thumbnail_url.endswith("_small.jpeg")) + # test local image test_file.db_set('thumbnail_url', None) test_file.reload() From 897b8a5b66eaccf13ace7a3a9611f682b98624db Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 19 Jan 2022 19:30:08 +0530 Subject: [PATCH 035/151] test: Add test for hosting binary file using StaticPage resolver --- frappe/tests/test_website.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py index e40a07c0ec..107a0eaf95 100644 --- a/frappe/tests/test_website.py +++ b/frappe/tests/test_website.py @@ -1,7 +1,9 @@ import unittest +from unittest.mock import patch import frappe from frappe.utils import set_request +from frappe.website.page_renderers.static_page import StaticPage from frappe.website.serve import get_response, get_response_content from frappe.website.utils import (build_response, clear_website_cache, get_home_page) @@ -96,6 +98,19 @@ class TestWebsite(unittest.TestCase): response = get_response() self.assertEqual(response.status_code, 200) + set_request(method="GET", path="/_test/assets/image.jpg") + response = get_response() + self.assertEqual(response.status_code, 200) + + set_request(method="GET", path="/_test/assets/image") + response = get_response() + self.assertEqual(response.status_code, 200) + + with patch.object(StaticPage, "render") as static_render: + set_request(method="GET", path="/_test/assets/image") + response = get_response() + static_render.assert_called() + def test_error_page(self): set_request(method='GET', path='/_test/problematic_page') response = get_response() @@ -126,7 +141,6 @@ class TestWebsite(unittest.TestCase): response = get_response() self.assertEqual(response.status_code, 404) - def test_redirect(self): import frappe.hooks frappe.set_user('Administrator') From 9dbaf252f0acaac73ecb5181d0eba453dbcef973 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 19 Jan 2022 19:30:45 +0530 Subject: [PATCH 036/151] fix: Check if binary file in Page Renderers * Check if binary before rendering using StaticPage resolver * Check if not binary before rendering using TemplatePage resolver --- frappe/website/page_renderers/static_page.py | 6 +++--- frappe/website/page_renderers/template_page.py | 4 ++-- frappe/website/utils.py | 12 ++++++++++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/frappe/website/page_renderers/static_page.py b/frappe/website/page_renderers/static_page.py index 632e9b4302..48bf0f2aec 100644 --- a/frappe/website/page_renderers/static_page.py +++ b/frappe/website/page_renderers/static_page.py @@ -6,6 +6,7 @@ from werkzeug.wsgi import wrap_file import frappe from frappe.website.page_renderers.base_renderer import BaseRenderer +from frappe.website.utils import is_binary_file UNSUPPORTED_STATIC_PAGE_TYPES = ('html', 'md', 'js', 'xml', 'css', 'txt', 'py', 'json') @@ -20,21 +21,20 @@ class StaticPage(BaseRenderer): return for app in frappe.get_installed_apps(): file_path = frappe.get_app_path(app, 'www') + '/' + self.path - if os.path.isfile(file_path): + if os.path.isfile(file_path) and is_binary_file(file_path): self.file_path = file_path def can_render(self): return self.is_valid_file_path() and self.file_path def is_valid_file_path(self): - if ('.' not in self.path): - return False extension = self.path.rsplit('.', 1)[-1] if extension in UNSUPPORTED_STATIC_PAGE_TYPES: return False return True def render(self): + # file descriptor to be left open, closed by middleware f = open(self.file_path, 'rb') response = Response(wrap_file(frappe.local.request.environ, f), direct_passthrough=True) response.mimetype = mimetypes.guess_type(self.file_path)[0] or 'application/octet-stream' diff --git a/frappe/website/page_renderers/template_page.py b/frappe/website/page_renderers/template_page.py index cf017be30b..ff3e8509bd 100644 --- a/frappe/website/page_renderers/template_page.py +++ b/frappe/website/page_renderers/template_page.py @@ -7,7 +7,7 @@ from frappe.website.router import get_page_info from frappe.website.page_renderers.base_template_page import BaseTemplatePage from frappe.website.router import get_base_template from frappe.website.utils import (extract_comment_tag, extract_title, get_next_link, - get_toc, get_frontmatter, cache_html, get_sidebar_items) + get_toc, get_frontmatter, is_binary_file, cache_html, get_sidebar_items) WEBPAGE_PY_MODULE_PROPERTIES = ("base_template_path", "template", "no_cache", "sitemap", "condition_field") @@ -39,7 +39,7 @@ class TemplatePage(BaseTemplatePage): for dirname in folders: search_path = os.path.join(app_path, dirname, self.path) for file_path in self.get_index_path_options(search_path): - if os.path.isfile(file_path): + if os.path.isfile(file_path) and not is_binary_file(file_path): self.app = app self.app_path = app_path self.file_dir = dirname diff --git a/frappe/website/utils.py b/frappe/website/utils.py index cb8008277c..27c36923f2 100644 --- a/frappe/website/utils.py +++ b/frappe/website/utils.py @@ -1,10 +1,10 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import json import mimetypes import os import re -from functools import wraps +from functools import cache, wraps import yaml from six import iteritems @@ -511,3 +511,11 @@ def add_preload_headers(response): except Exception: import traceback traceback.print_exc() + +@cache +def is_binary_file(path): + # ref: https://stackoverflow.com/a/7392391/10309266 + textchars = bytearray({7,8,9,10,12,13,27} | set(range(0x20, 0x100)) - {0x7f}) + with open(path, 'rb') as f: + content = f.read(1024) + return bool(content.translate(None, textchars)) From a62da8ddeb74a0b19766d89aee71689d422e06ba Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 21 Jan 2022 13:05:54 +0100 Subject: [PATCH 037/151] feat: translate column names in export of report --- frappe/desk/reportview.py | 17 +++++++++++------ .../js/frappe/views/reports/report_view.js | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index e81ed0767b..fa96596841 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -305,7 +305,7 @@ def export_query(): if add_totals_row: ret = append_totals_row(ret) - data = [['Sr'] + get_labels(db_query.fields, doctype)] + data = [[_('Sr')] + get_labels(db_query.fields, doctype)] for i, row in enumerate(ret): data.append([i+1] + list(row)) @@ -364,7 +364,8 @@ def get_labels(fields, doctype): for key in fields: key = key.split(" as ")[0] - if key.startswith(('count(', 'sum(', 'avg(')): continue + if key.startswith(('count(', 'sum(', 'avg(')): + continue if "." in key: parenttype, fieldname = key.split(".")[0][4:-1], key.split(".")[1].strip("`") @@ -372,10 +373,14 @@ def get_labels(fields, doctype): parenttype = doctype fieldname = fieldname.strip("`") - df = frappe.get_meta(parenttype).get_field(fieldname) - label = df.label if df else fieldname.title() - if label in labels: - label = doctype + ": " + label + if parenttype == doctype and fieldname == "name": + label = _("ID", context="Label of name column in report") + else: + df = frappe.get_meta(parenttype).get_field(fieldname) + label = _(df.label if df else fieldname.title()) + if parenttype != doctype: + label += " (" + _(parenttype) + ")" + labels.append(label) return labels diff --git a/frappe/public/js/frappe/views/reports/report_view.js b/frappe/public/js/frappe/views/reports/report_view.js index 6d8e281793..71cab6b6c2 100644 --- a/frappe/public/js/frappe/views/reports/report_view.js +++ b/frappe/public/js/frappe/views/reports/report_view.js @@ -860,7 +860,7 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView { } doctype_fields = [{ - label: __('ID'), + label: __('ID', null, 'Label of name column in report'), fieldname: 'name', fieldtype: 'Data', reqd: 1 From c9946b2899c62352d75a3002fcbf1ff21d306166 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Mon, 24 Jan 2022 16:07:48 +0100 Subject: [PATCH 038/151] style: add comment --- frappe/desk/reportview.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index fa96596841..49128bfc93 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -379,6 +379,8 @@ def get_labels(fields, doctype): df = frappe.get_meta(parenttype).get_field(fieldname) label = _(df.label if df else fieldname.title()) if parenttype != doctype: + # If the column is from a child table, append the child doctype. + # For example, "Item Code (Sales Invoice Item)". label += " (" + _(parenttype) + ")" labels.append(label) From efd5c197cbec0a483eccf2e2def7f33e4876ea08 Mon Sep 17 00:00:00 2001 From: saxenabhishek Date: Tue, 25 Jan 2022 03:07:34 +0530 Subject: [PATCH 039/151] fix: sbool converting int stored as string --- frappe/utils/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 8148d194c6..b13044b2e6 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -750,7 +750,7 @@ def sbool(x: str) -> Union[bool, Any]: return True elif val in ('false', '0'): return False - return bool(x) + return x except Exception: return x From 97d6a9641911de8e895ea7e02c8e41edec4a83ae Mon Sep 17 00:00:00 2001 From: saxenabhishek Date: Tue, 25 Jan 2022 13:28:11 +0530 Subject: [PATCH 040/151] fix: timedelta parsing in pypika --- frappe/query_builder/terms.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frappe/query_builder/terms.py b/frappe/query_builder/terms.py index 71f60af656..f9751612b6 100644 --- a/frappe/query_builder/terms.py +++ b/frappe/query_builder/terms.py @@ -1,3 +1,4 @@ +from datetime import timedelta from typing import Any, Dict, Optional from pypika.terms import Function, ValueWrapper @@ -51,6 +52,9 @@ class ParameterizedValueWrapper(ValueWrapper): value_sql = self.get_value_sql(quote_char=quote_char, **kwargs) sql = param_wrapper.get_sql(param_value=value_sql, **kwargs) else: + # * BUG: pypika doesen't parse timedeltas + if isinstance(self.value, timedelta): + self.value = str(self.value) sql = self.get_value_sql( quote_char=quote_char, secondary_quote_char=secondary_quote_char, From 1741c478261f6ecc39d8b7adb976c35e243fe147 Mon Sep 17 00:00:00 2001 From: saxenabhishek Date: Tue, 25 Jan 2022 15:26:16 +0530 Subject: [PATCH 041/151] test: avoid parameterization of qb objects --- frappe/tests/test_query_builder.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_query_builder.py b/frappe/tests/test_query_builder.py index bc98e166ea..bb7a883b5b 100644 --- a/frappe/tests/test_query_builder.py +++ b/frappe/tests/test_query_builder.py @@ -5,7 +5,7 @@ import frappe from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.functions import Coalesce, GroupConcat, Match from frappe.query_builder.utils import db_type_is - +from frappe.query_builder import Case def run_only_if(dbtype: db_type_is) -> Callable: return unittest.skipIf( @@ -108,6 +108,27 @@ class TestParameterization(unittest.TestCase): self.assertIn("param1", params) self.assertEqual(params["param1"], "subject") + def test_case(self): + DocType = frappe.qb.DocType("DocType") + query = ( + frappe.qb.from_(DocType) + .select( + Case() + .when(DocType.search_fields == "value", "other_value") + .when(Coalesce(DocType.search_fields == "subject_in_function"), "true_value") + ) + ) + + self.assertTrue("walk" in dir(query)) + query, params = query.walk() + + self.assertIn("%(param1)s", query) + self.assertIn("param1", params) + self.assertEqual(params["param1"], "value") + self.assertEqual(params["param2"], "other_value") + self.assertEqual(params["param3"], "subject_in_function") + self.assertEqual(params["param4"], "true_value") + @run_only_if(db_type_is.MARIADB) class TestBuilderMaria(unittest.TestCase, TestBuilderBase): From 946a06383318875202208c6be2e2b47433dd0054 Mon Sep 17 00:00:00 2001 From: Mohammad Hasnain Date: Wed, 26 Jan 2022 12:31:45 +0530 Subject: [PATCH 042/151] test(user permission): create new blog as stub --- frappe/core/doctype/user_permission/test_user_permission.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py index ccc9df9851..308d65210e 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -3,6 +3,7 @@ from frappe.core.doctype.user_permission.user_permission import add_user_permissions, remove_applicable from frappe.permissions import has_user_permission from frappe.core.doctype.doctype.test_doctype import new_doctype +from frappe.website.doctype.blog_post.test_blog_post import make_test_blog import frappe import unittest @@ -37,7 +38,8 @@ class TestUserPermission(unittest.TestCase): add_user_permissions(param) #create a duplicate entry with default perm_user = create_user('test_default_corectness2@example.com') - param = get_params(perm_user, 'Blog Post', perm_user.name, is_default=1, hide_descendants= 1) + test_blog = make_test_blog() + param = get_params(perm_user, 'Blog Post', test_blog.name, is_default=1, hide_descendants= 1) add_user_permissions(param) def test_default_user_permission(self): From 4cab8ca30a6c857f1f9e90fd5893901021c8eb87 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 27 Jan 2022 18:56:05 +0530 Subject: [PATCH 043/151] fix(test): Add missing test image for test_make_thumbnail --- frappe/www/_test/assets/image | Bin 0 -> 161713 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 frappe/www/_test/assets/image diff --git a/frappe/www/_test/assets/image b/frappe/www/_test/assets/image new file mode 100644 index 0000000000000000000000000000000000000000..4a2c1552b991add9a356a460415626ad700f401b GIT binary patch literal 161713 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 literal 0 HcmV?d00001 From 07ae6855c39ca37762f6d6e12fc30fa11bdcb508 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Fri, 28 Jan 2022 12:01:38 +0100 Subject: [PATCH 044/151] fix: use format string Co-authored-by: Himanshu --- frappe/desk/reportview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index 49128bfc93..489e1044de 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -381,7 +381,7 @@ def get_labels(fields, doctype): if parenttype != doctype: # If the column is from a child table, append the child doctype. # For example, "Item Code (Sales Invoice Item)". - label += " (" + _(parenttype) + ")" + label += f" ({ _(parenttype) })" labels.append(label) From 9091b2a037635015bbe16ca205047cd2aaa3a8bc Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 28 Jan 2022 18:31:35 +0530 Subject: [PATCH 045/151] feat(minor): Case option in run-tests for specifying TestCase --- frappe/commands/utils.py | 5 +++-- frappe/test_runner.py | 17 ++++++++++------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 41b607b192..e3379a43aa 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -623,6 +623,7 @@ def transform_database(context, table, engine, row_format, failfast): @click.command('run-tests') @click.option('--app', help="For App") @click.option('--doctype', help="For DocType") +@click.option('--case', help="Select particular TestCase") @click.option('--doctype-list-path', help="Path to .txt file for list of doctypes. Example erpnext/tests/server/agriculture.txt") @click.option('--test', multiple=True, help="Specific test") @click.option('--ui-tests', is_flag=True, default=False, help="Run UI Tests") @@ -636,7 +637,7 @@ def transform_database(context, table, engine, row_format, failfast): @pass_context def run_tests(context, app=None, module=None, doctype=None, test=(), profile=False, coverage=False, junit_xml_output=False, ui_tests = False, doctype_list_path=None, - skip_test_records=False, skip_before_tests=False, failfast=False): + skip_test_records=False, skip_before_tests=False, failfast=False, case=None): with CodeCoverage(coverage, app): import frappe.test_runner @@ -658,7 +659,7 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), profile=Fal ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests, force=context.force, profile=profile, junit_xml_output=junit_xml_output, - ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast) + ui_tests=ui_tests, doctype_list_path=doctype_list_path, failfast=failfast, case=case) if len(ret.failures) == 0 and len(ret.errors) == 0: ret = 0 diff --git a/frappe/test_runner.py b/frappe/test_runner.py index 1839f15ae8..05f1ce1cd7 100644 --- a/frappe/test_runner.py +++ b/frappe/test_runner.py @@ -30,7 +30,7 @@ def xmlrunner_wrapper(output): def main(app=None, module=None, doctype=None, verbose=False, tests=(), force=False, profile=False, junit_xml_output=None, ui_tests=False, - doctype_list_path=None, skip_test_records=False, failfast=False): + doctype_list_path=None, skip_test_records=False, failfast=False, case=None): global unittest_runner if doctype_list_path: @@ -76,7 +76,7 @@ def main(app=None, module=None, doctype=None, verbose=False, tests=(), if doctype: ret = run_tests_for_doctype(doctype, verbose, tests, force, profile, failfast=failfast, junit_xml_output=junit_xml_output) elif module: - ret = run_tests_for_module(module, verbose, tests, profile, failfast=failfast, junit_xml_output=junit_xml_output) + ret = run_tests_for_module(module, verbose, tests, profile, failfast=failfast, junit_xml_output=junit_xml_output, case=case) else: ret = run_all_tests(app, verbose, profile, ui_tests, failfast=failfast, junit_xml_output=junit_xml_output) @@ -182,16 +182,16 @@ def run_tests_for_doctype(doctypes, verbose=False, tests=(), force=False, profil return _run_unittest(modules, verbose=verbose, tests=tests, profile=profile, failfast=failfast, junit_xml_output=junit_xml_output) -def run_tests_for_module(module, verbose=False, tests=(), profile=False, failfast=False, junit_xml_output=False): +def run_tests_for_module(module, verbose=False, tests=(), profile=False, failfast=False, junit_xml_output=False, case=None): module = importlib.import_module(module) if hasattr(module, "test_dependencies"): for doctype in module.test_dependencies: make_test_records(doctype, verbose=verbose) frappe.db.commit() - return _run_unittest(module, verbose=verbose, tests=tests, profile=profile, failfast=failfast, junit_xml_output=junit_xml_output) + return _run_unittest(module, verbose=verbose, tests=tests, profile=profile, failfast=failfast, junit_xml_output=junit_xml_output, case=case) -def _run_unittest(modules, verbose=False, tests=(), profile=False, failfast=False, junit_xml_output=False): +def _run_unittest(modules, verbose=False, tests=(), profile=False, failfast=False, junit_xml_output=False, case=None): frappe.db.begin() test_suite = unittest.TestSuite() @@ -200,7 +200,10 @@ def _run_unittest(modules, verbose=False, tests=(), profile=False, failfast=Fals modules = [modules] for module in modules: - module_test_cases = unittest.TestLoader().loadTestsFromModule(module) + if case: + module_test_cases = unittest.TestLoader().loadTestsFromTestCase(getattr(module, case)) + else: + module_test_cases = unittest.TestLoader().loadTestsFromModule(module) if tests: for each in module_test_cases: for test_case in each.__dict__["_tests"]: @@ -337,7 +340,7 @@ def make_test_records_for_doctype(doctype, verbose=0, force=False): elif hasattr(test_module, "test_records"): if doctype in frappe.local.test_objects: frappe.local.test_objects[doctype] += make_test_objects(doctype, test_module.test_records, verbose, force) - else: + else: frappe.local.test_objects[doctype] = make_test_objects(doctype, test_module.test_records, verbose, force) else: From 8037866dc1d79476e1dd6607a85f3eb79edfbdd0 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 28 Jan 2022 18:33:03 +0530 Subject: [PATCH 046/151] fix: Handle parsing and formatting timedeltas * Added utils parse_timedelta, format_timedelta * Added to json_handler for de-serializing timedelta objects --- frappe/utils/__init__.py | 2 +- frappe/utils/data.py | 37 +++++++++++++++++++++++++++++++++++-- frappe/utils/formatters.py | 9 +++++++-- frappe/utils/response.py | 14 ++++++++------ 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index a275c4f64b..fb2e91a262 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import functools diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 8148d194c6..6cd0c45995 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -104,11 +104,17 @@ def get_timedelta(time: Optional[str] = None) -> Optional[datetime.timedelta]: datetime.timedelta: Timedelta object equivalent of the passed `time` string """ from dateutil import parser + from dateutil.parser import ParserError time = time or "0:0:0" try: - t = parser.parse(time) + try: + t = parser.parse(time) + except ParserError as e: + if "day" in e.args[1]: + from frappe.utils import parse_timedelta + return parse_timedelta(time) return datetime.timedelta( hours=t.hour, minutes=t.minute, seconds=t.second, microseconds=t.microsecond ) @@ -332,7 +338,7 @@ def get_time(time_str): return time_str else: if isinstance(time_str, datetime.timedelta): - time_str = str(time_str) + return format_timedelta(time_str) return parser.parse(time_str).time() def get_datetime_str(datetime_obj): @@ -1678,3 +1684,30 @@ class UnicodeWithAttrs(str): def __init__(self, text): self.toc_html = text.toc_html self.metadata = text.metadata + + +def format_timedelta(o: datetime.timedelta) -> str: + # mariadb allows a wide diff range - https://mariadb.com/kb/en/time/ + # but frappe doesnt - i think via babel : only allows 0..23 range for hour + total_seconds = o.total_seconds() + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + rounded_seconds = round(seconds, 6) + int_seconds = int(seconds) + + if rounded_seconds == int_seconds: + seconds = int_seconds + else: + seconds = rounded_seconds + + return "{:01}:{:02}:{:02}".format(int(hours), int(minutes), seconds) + + +def parse_timedelta(s: str) -> datetime.timedelta: + # ref: https://stackoverflow.com/a/21074460/10309266 + if 'day' in s: + m = re.match(r"(?P[-\d]+) day[s]*, (?P\d+):(?P\d+):(?P\d[\.\d+]*)", s) + else: + m = re.match(r"(?P\d+):(?P\d+):(?P\d[\.\d+]*)", s) + + return datetime.timedelta(**{key: float(val) for key, val in m.groupdict().items()}) diff --git a/frappe/utils/formatters.py b/frappe/utils/formatters.py index 9436dea2c2..9916853caf 100644 --- a/frappe/utils/formatters.py +++ b/frappe/utils/formatters.py @@ -3,9 +3,11 @@ import frappe import datetime -from frappe.utils import formatdate, fmt_money, flt, cstr, cint, format_datetime, format_time, format_duration +from frappe.utils import formatdate, fmt_money, flt, cstr, cint, format_datetime, format_time, format_duration, format_timedelta from frappe.model.meta import get_field_currency, get_field_precision import re +from dateutil.parser import ParserError + def format_value(value, df=None, doc=None, currency=None, translated=False, format=None): '''Format value based on given fieldtype, document reference, currency reference. @@ -47,7 +49,10 @@ def format_value(value, df=None, doc=None, currency=None, translated=False, form return format_datetime(value) elif df.get("fieldtype")=="Time": - return format_time(value) + try: + return format_time(value) + except ParserError: + return format_timedelta(value) elif value==0 and df.get("fieldtype") in ("Int", "Float", "Currency", "Percent") and df.get("print_hide_if_no_value"): # this is required to show 0 as blank in table columns diff --git a/frappe/utils/response.py b/frappe/utils/response.py index f6ad91dbd2..a852c584c6 100644 --- a/frappe/utils/response.py +++ b/frappe/utils/response.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import json @@ -16,7 +16,7 @@ from werkzeug.local import LocalProxy from werkzeug.wsgi import wrap_file from werkzeug.wrappers import Response from werkzeug.exceptions import NotFound, Forbidden -from frappe.utils import cint +from frappe.utils import cint, format_timedelta from urllib.parse import quote from frappe.core.doctype.access_log.access_log import make_access_log @@ -122,12 +122,14 @@ def make_logs(response = None): def json_handler(obj): """serialize non-serializable data for json""" - # serialize date - import collections.abc + from collections.abc import Iterable - if isinstance(obj, (datetime.date, datetime.timedelta, datetime.datetime, datetime.time)): + if isinstance(obj, (datetime.date, datetime.datetime, datetime.time)): return str(obj) + elif isinstance(obj, datetime.timedelta): + return format_timedelta(obj) + elif isinstance(obj, decimal.Decimal): return float(obj) @@ -138,7 +140,7 @@ def json_handler(obj): doc = obj.as_dict(no_nulls=True) return doc - elif isinstance(obj, collections.abc.Iterable): + elif isinstance(obj, Iterable): return list(obj) elif type(obj)==type or isinstance(obj, Exception): From 4990a59c48ba7948489e262e02a377d788bcc8ce Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 28 Jan 2022 18:35:27 +0530 Subject: [PATCH 047/151] test: Added unit tests for format_timedelta, parse_timedelta, json_handler --- frappe/tests/test_utils.py | 68 +++++++++++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 4 deletions(-) diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 5c1541e0de..83ae6df191 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -1,11 +1,14 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +from decimal import Decimal +from enum import Enum import unittest import frappe from frappe.utils import evaluate_filters, money_in_words, scrub_urls, get_url from frappe.utils import validate_url, validate_email_address from frappe.utils import ceil, floor +from frappe.utils import format_timedelta, parse_timedelta from frappe.utils.data import cast, validate_python_code from frappe.utils.diff import get_version_diff, version_query, _get_value_from_version @@ -13,10 +16,12 @@ from PIL import Image from frappe.utils.image import strip_exif_data, optimize_image import io from mimetypes import guess_type -from datetime import datetime, timedelta, date +from datetime import datetime, time, timedelta, date from unittest.mock import patch +import pytz + class TestFilters(unittest.TestCase): def test_simple_dict(self): self.assertTrue(evaluate_filters({'doctype': 'User', 'status': 'Open'}, {'status': 'Open'})) @@ -275,7 +280,6 @@ class TestPythonExpressions(unittest.TestCase): class TestDiffUtils(unittest.TestCase): - @classmethod def setUpClass(cls): cls.doc = frappe.get_doc(doctype="Client Script", dt="Client Script") @@ -328,4 +332,60 @@ class TestDateUtils(unittest.TestCase): self.assertEqual(frappe.utils.get_last_day_of_week("2020-12-24"), frappe.utils.getdate("2020-12-26")) self.assertEqual(frappe.utils.get_last_day_of_week("2020-12-28"), - frappe.utils.getdate("2021-01-02")) \ No newline at end of file + frappe.utils.getdate("2021-01-02")) + + +class TestResponse(unittest.TestCase): + def test_json_handler(self): + import json + from frappe.utils.response import json_handler + + class TEST(Enum): + ABC = "!@)@)!" + BCE = "ENJD" + + GOOD_OBJECT = { + "time_types": [ + date(year=2020, month=12, day=2), + datetime(year=2020, month=12, day=2, hour=23, minute=23, second=23, microsecond=23, tzinfo=pytz.utc), + time(hour=23, minute=23, second=23, microsecond=23, tzinfo=pytz.utc), + timedelta(days=10, hours=12, minutes=120, seconds=10), + ], + "float": [ + Decimal(29.21), + ], + "doc": [ + frappe.get_doc("System Settings"), + ], + "iter": [ + {1, 2, 3}, + (1, 2, 3), + "abcdef", + ], + "string": "abcdef" + } + + BAD_OBJECT = {"Enum": TEST} + + processed_object = json.loads(json.dumps(GOOD_OBJECT, default=json_handler)) + + self.assertTrue(all([isinstance(x, str) for x in processed_object["time_types"]])) + self.assertTrue(all([isinstance(x, float) for x in processed_object["float"]])) + self.assertTrue(all([isinstance(x, (list, str)) for x in processed_object["iter"]])) + self.assertIsInstance(processed_object["string"], str) + with self.assertRaises(TypeError): + json.dumps(BAD_OBJECT, default=json_handler) + +class TestTimeDeltaUtils(unittest.TestCase): + def test_format_timedelta(self): + self.assertEqual(format_timedelta(timedelta(seconds=0)), "0:00:00") + self.assertEqual(format_timedelta(timedelta(hours=10)), "10:00:00") + self.assertEqual(format_timedelta(timedelta(hours=100)), "100:00:00") + self.assertEqual(format_timedelta(timedelta(seconds=100, microseconds=129)), "0:01:40.000129") + self.assertEqual(format_timedelta(timedelta(seconds=100, microseconds=12212199129)), "3:25:12.199129") + + def test_parse_timedelta(self): + self.assertEqual(parse_timedelta("0:0:0"), timedelta(seconds=0)) + self.assertEqual(parse_timedelta("10:0:0"), timedelta(hours=10)) + self.assertEqual(parse_timedelta("7 days, 0:32:18.192221"), timedelta(days=7, seconds=1938, microseconds=192221)) + self.assertEqual(parse_timedelta("7 days, 0:32:18"), timedelta(days=7, seconds=1938)) From e080eab06b9af2b6b47123ffcde36f160f87b7df Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 28 Jan 2022 18:38:13 +0530 Subject: [PATCH 048/151] style: Sort imports --- frappe/tests/test_utils.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 83ae6df191..dd4bd3518f 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -1,26 +1,28 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE + +import io +import json +import unittest +from datetime import date, datetime, time, timedelta from decimal import Decimal from enum import Enum -import unittest -import frappe - -from frappe.utils import evaluate_filters, money_in_words, scrub_urls, get_url -from frappe.utils import validate_url, validate_email_address -from frappe.utils import ceil, floor -from frappe.utils import format_timedelta, parse_timedelta -from frappe.utils.data import cast, validate_python_code -from frappe.utils.diff import get_version_diff, version_query, _get_value_from_version - -from PIL import Image -from frappe.utils.image import strip_exif_data, optimize_image -import io from mimetypes import guess_type -from datetime import datetime, time, timedelta, date - from unittest.mock import patch import pytz +from PIL import Image + +import frappe +from frappe.utils import ceil, evaluate_filters, floor, format_timedelta +from frappe.utils import get_url, money_in_words, parse_timedelta, scrub_urls +from frappe.utils import validate_email_address, validate_url +from frappe.utils.data import cast, validate_python_code +from frappe.utils.diff import _get_value_from_version, get_version_diff, version_query +from frappe.utils.image import optimize_image, strip_exif_data +from frappe.utils.response import json_handler + + class TestFilters(unittest.TestCase): def test_simple_dict(self): @@ -337,9 +339,6 @@ class TestDateUtils(unittest.TestCase): class TestResponse(unittest.TestCase): def test_json_handler(self): - import json - from frappe.utils.response import json_handler - class TEST(Enum): ABC = "!@)@)!" BCE = "ENJD" From b632cc558bc38e274161b91efeeb60ad1b7f59a6 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Fri, 28 Jan 2022 18:47:57 +0530 Subject: [PATCH 049/151] fix: remove unnecessary array transformation in request args `key: ['value', 'value2']` is turned into `key: 'value'` for no reason --- frappe/app.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/frappe/app.py b/frappe/app.py index d73dd67983..609a8535d7 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -192,12 +192,7 @@ def make_form_dict(request): if not isinstance(args, dict): frappe.throw(_("Invalid request arguments")) - try: - frappe.local.form_dict = frappe._dict({ - k: v[0] if isinstance(v, (list, tuple)) else v for k, v in args.items() - }) - except IndexError: - frappe.local.form_dict = frappe._dict(args) + frappe.local.form_dict = frappe._dict(args) if "_" in frappe.local.form_dict: # _ is passed by $.ajax so that the request is not cached by the browser. So, remove _ from form_dict From e198217d13a73f619b512f13b0f9ca3a44ebb4d8 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 29 Jan 2022 12:20:45 +0530 Subject: [PATCH 050/151] fix: Show Reset Changes only when there are changes --- .../js/print_format_builder/print_format_builder.bundle.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/print_format_builder/print_format_builder.bundle.js b/frappe/public/js/print_format_builder/print_format_builder.bundle.js index b2d3372daf..c8c03d209a 100644 --- a/frappe/public/js/print_format_builder/print_format_builder.bundle.js +++ b/frappe/public/js/print_format_builder/print_format_builder.bundle.js @@ -21,7 +21,7 @@ class PrintFormatBuilder { this.$component.toggle_preview(); } ); - this.page.add_button(__("Reset Changes"), () => + let $reset_changes_btn = this.page.add_button(__("Reset Changes"), () => this.$component.$store.reset_changes() ); this.page.add_menu_item(__("Edit Print Format"), () => { @@ -46,9 +46,11 @@ class PrintFormatBuilder { if (value) { this.page.set_indicator("Not Saved", "orange"); $toggle_preview_btn.hide(); + $reset_changes_btn.show(); } else { this.page.clear_indicator(); $toggle_preview_btn.show(); + $reset_changes_btn.hide(); } }); this.$component.$watch("show_preview", value => { From 7223f749dcecefc948e8a75c50559044d97beb78 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 29 Jan 2022 12:21:01 +0530 Subject: [PATCH 051/151] fix: child table columns overflow --- frappe/templates/print_format/print_format.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frappe/templates/print_format/print_format.css b/frappe/templates/print_format/print_format.css index 480cd19439..baaf5b087d 100644 --- a/frappe/templates/print_format/print_format.css +++ b/frappe/templates/print_format/print_format.css @@ -57,6 +57,10 @@ body { padding-left: {{ print_format.margin_left | int }}mm; padding-bottom: {{ print_format.margin_bottom | int }}mm; } + + .child-table { + overflow-x: auto; + } } .section:not(:first-child) { From 8d9b46527f36901f9b9032bb5ea867dd50a51e0f Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 31 Jan 2022 11:29:32 +0530 Subject: [PATCH 052/151] chore: Remove dead imports --- frappe/core/doctype/file/file.py | 3 +-- frappe/core/doctype/file/test_file.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 4140236c17..89295fb52f 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -1,4 +1,4 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE """ @@ -7,7 +7,6 @@ record of files naming for same name files: file.gif, file-1.gif, file-2.gif etc """ -import base64 import hashlib import imghdr import io diff --git a/frappe/core/doctype/file/test_file.py b/frappe/core/doctype/file/test_file.py index e415772d9f..942558ee60 100644 --- a/frappe/core/doctype/file/test_file.py +++ b/frappe/core/doctype/file/test_file.py @@ -5,10 +5,9 @@ import json import frappe import os import unittest -from unittest.mock import patch from frappe import _ -from frappe.core.doctype.file.file import get_attached_images, get_web_image, move_file, get_files_in_folder, unzip_file +from frappe.core.doctype.file.file import get_attached_images, move_file, get_files_in_folder, unzip_file from frappe.utils import get_files_path test_content1 = 'Hello' From adc745a72c5b005b515de1259440eb55c330a3f2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 31 Jan 2022 11:43:24 +0530 Subject: [PATCH 053/151] fix: Avoid TypeError when guess_extension returns None --- frappe/core/doctype/file/file.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index 89295fb52f..ee2c9987b6 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -660,7 +660,9 @@ def get_extension(filename, extn, content: bytes = None, response: "Response" = content_type = response.headers.get("Content-Type") if content_type: - return mimetypes.guess_extension(content_type)[1:] + _extn = mimetypes.guess_extension(content_type) + if _extn: + return _extn[1:] if extn: # remove '?' char and parameters from extn if present From 229e259bd284625eff1a8b1d213e5b6483560273 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 31 Jan 2022 11:56:09 +0530 Subject: [PATCH 054/151] fix: Format timedelta object accurately --- frappe/query_builder/terms.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/query_builder/terms.py b/frappe/query_builder/terms.py index f9751612b6..bbc316ca93 100644 --- a/frappe/query_builder/terms.py +++ b/frappe/query_builder/terms.py @@ -1,5 +1,6 @@ from datetime import timedelta from typing import Any, Dict, Optional +from frappe.utils.data import format_timedelta from pypika.terms import Function, ValueWrapper from pypika.utils import format_alias_sql @@ -54,7 +55,7 @@ class ParameterizedValueWrapper(ValueWrapper): else: # * BUG: pypika doesen't parse timedeltas if isinstance(self.value, timedelta): - self.value = str(self.value) + self.value = format_timedelta(self.value) sql = self.get_value_sql( quote_char=quote_char, secondary_quote_char=secondary_quote_char, From 3ebed90d37f5487b76ee2fc2181332ad501434f9 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 31 Jan 2022 12:51:42 +0530 Subject: [PATCH 055/151] fix: child table filtering in multi select dialog --- .../js/frappe/form/multi_select_dialog.js | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/frappe/public/js/frappe/form/multi_select_dialog.js b/frappe/public/js/frappe/form/multi_select_dialog.js index bc0286e62d..bc5f7a9b52 100644 --- a/frappe/public/js/frappe/form/multi_select_dialog.js +++ b/frappe/public/js/frappe/form/multi_select_dialog.js @@ -150,8 +150,12 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { }); } + is_child_selection_enabled() { + return this.dialog.fields_dict['allow_child_item_selection'].get_value(); + } + toggle_child_selection() { - if (this.dialog.fields_dict['allow_child_item_selection'].get_value()) { + if (this.is_child_selection_enabled()) { this.show_child_results(); } else { this.child_results = []; @@ -289,7 +293,11 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { parent: this.dialog.get_field('filter_area').$wrapper, doctype: this.doctype, on_change: () => { - this.get_results(); + if (this.is_child_selection_enabled()) { + this.show_child_results(); + } else { + this.get_results(); + } } }); // 'Apply Filter' breaks since the filers are not in a popover @@ -325,7 +333,11 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { this.$parent.find('.input-with-feedback').on('change', () => { frappe.flags.auto_scroll = false; - this.get_results(); + if (this.is_child_selection_enabled()) { + this.show_child_results(); + } else { + this.get_results(); + } }); this.$parent.find('[data-fieldtype="Data"]').on('input', () => { @@ -333,8 +345,12 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { clearTimeout($this.data('timeout')); $this.data('timeout', setTimeout(function () { frappe.flags.auto_scroll = false; - me.empty_list(); - me.get_results(); + if (me.is_child_selection_enabled()) { + me.show_child_results(); + } else { + me.empty_list(); + me.get_results(); + } }, 300)); }); } From 0eab7ed02109d976868a57c893ceac5181183633 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 31 Jan 2022 13:31:31 +0530 Subject: [PATCH 056/151] fix: Call validate_link only if "value" is passed --- frappe/public/js/frappe/form/controls/link.js | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index ed355cf8b4..7f671c6e8b 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -458,7 +458,6 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat validate_link_and_fetch(df, options, docname, value) { if (!options) return; - let field_value = ""; const fetch_map = this.fetch_map; const columns_to_fetch = Object.values(fetch_map); @@ -467,16 +466,10 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat return value; } - return frappe.xcall("frappe.client.validate_link", { - doctype: options, - docname: value, - fields: columns_to_fetch, - }).then((response) => { - if (!docname || !columns_to_fetch.length) return response.name; - + function update_dependant_fields(response) { + let field_value = ""; for (const [target_field, source_field] of Object.entries(fetch_map)) { if (value) field_value = response[source_field]; - frappe.model.set_value( df.parent, docname, @@ -485,9 +478,23 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat df.fieldtype, ); } + } - return response.name; - }); + // to avoid unnecessary request + if (value) { + return frappe.xcall("frappe.client.validate_link", { + doctype: options, + docname: value, + fields: columns_to_fetch, + }).then((response) => { + if (!docname || !columns_to_fetch.length) return response.name; + update_dependant_fields(response); + return response.name; + }); + } else { + update_dependant_fields({}); + return value; + } } get fetch_map() { From 38393c56df51778d425ced66a9b2e214d13d52bf Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 31 Jan 2022 13:37:38 +0530 Subject: [PATCH 057/151] test(User Type): add select perm doctypes --- .../core/doctype/user_type/test_user_type.py | 64 ++++++++++++++++++- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/user_type/test_user_type.py b/frappe/core/doctype/user_type/test_user_type.py index 7080e1830b..6807f8fc9e 100644 --- a/frappe/core/doctype/user_type/test_user_type.py +++ b/frappe/core/doctype/user_type/test_user_type.py @@ -1,8 +1,68 @@ # -*- coding: utf-8 -*- # Copyright (c) 2021, Frappe Technologies and Contributors # License: MIT. See LICENSE -# import frappe +import frappe import unittest +from frappe.installer import update_site_config + class TestUserType(unittest.TestCase): - pass + def setUp(self): + create_role() + + def test_add_select_perm_doctypes(self): + user_type = create_user_type('Test User Type') + + # select perms added for all link fields + doc = frappe.get_meta('Contact') + link_fields = doc.get_link_fields() + select_doctypes = frappe.get_all('User Select Document Type', {'parent': user_type.name}, pluck='document_type') + + for entry in link_fields: + self.assertTrue(entry.options in select_doctypes) + + # select perms added for all child table link fields + link_fields = [] + for child_table in doc.get_table_fields(): + child_doc = frappe.get_meta(child_table.options) + link_fields.extend(child_doc.get_link_fields()) + + for entry in link_fields: + self.assertTrue(entry.options in select_doctypes) + + def tearDown(self): + frappe.db.rollback() + + +def create_user_type(user_type): + if frappe.db.exists('User Type', user_type): + frappe.delete_doc('User Type', user_type) + + user_type_limit = {frappe.scrub(user_type): 1} + update_site_config('user_type_doctype_limit', user_type_limit) + + doc = frappe.get_doc({ + 'doctype': 'User Type', + 'name': user_type, + 'role': '_Test User Type', + 'user_id_field': 'user', + 'apply_user_permission_on': 'User' + }) + + doc.append('user_doctypes', { + 'document_type': 'Contact', + 'read': 1, + 'write': 1 + }) + + return doc.insert() + + +def create_role(): + if not frappe.db.exists('Role', '_Test User Type'): + frappe.get_doc({ + 'doctype': 'Role', + 'role_name': '_Test User Type', + 'desk_access': 1, + 'is_custom': 1 + }).insert() \ No newline at end of file From fadb07393ba75d1741dfd17f897b82a0002b9f19 Mon Sep 17 00:00:00 2001 From: Wolfram Schmidt Date: Mon, 31 Jan 2022 10:03:59 +0100 Subject: [PATCH 058/151] chore: Update de.csv (#15802) added missing translation in DocType User --- frappe/translations/de.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/translations/de.csv b/frappe/translations/de.csv index 0a541343f3..18ff55e386 100644 --- a/frappe/translations/de.csv +++ b/frappe/translations/de.csv @@ -1573,6 +1573,7 @@ Module Def,Modul-Def, Module Name,Modulname, Module Not Found,Modul nicht gefunden, Module Path,Modulpfad, +Module Profile, Modulprofil, Module to Export,Module für den Export, Modules HTML,Modul-HTML, Monospace,Monospace, From 5f64dac3c1e35101e6e88926e141f3da548f29a3 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Mon, 31 Jan 2022 15:23:08 +0530 Subject: [PATCH 059/151] test: Update UI test for link field --- cypress/integration/control_link.js | 62 ++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 6d16769b37..13ad52d237 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -58,6 +58,23 @@ context('Control Link', () => { cy.get('.frappe-control[data-fieldname=link] input').should('have.value', ''); }); + it("should be possible set empty value explicitly", () => { + get_dialog_with_link().as("dialog"); + + cy.intercept("POST", "/api/method/frappe.client.validate_link").as("validate_link"); + + cy.get(".frappe-control[data-fieldname=link] input") + .type(" ", { delay: 100 }) + .blur(); + cy.wait("@validate_link"); + cy.get(".frappe-control[data-fieldname=link] input").should("have.value", ""); + cy.window() + .its("cur_dialog") + .then((dialog) => { + expect(dialog.get_value("link")).to.equal(''); + }); + }); + it('should route to form on arrow click', () => { get_dialog_with_link().as('dialog'); @@ -78,7 +95,7 @@ context('Control Link', () => { }); }); - it('should fetch valid value', () => { + it('should update dependant fields (via fetch_from)', () => { cy.get('@todos').then(todos => { cy.visit(`/app/todo/${todos[0]}`); cy.intercept('POST', '/api/method/frappe.client.validate_link').as('validate_link'); @@ -89,7 +106,50 @@ context('Control Link', () => { cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( 'contain', 'Administrator' ); + + cy.window() + .its("cur_frm.doc.assigned_by") + .should("eq", "Administrator"); + + // invalid input + cy.get('@input').clear().type('invalid input', {delay: 100}).blur(); + cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( + 'contain', '' + ); + + cy.window() + .its("cur_frm.doc.assigned_by") + .should("eq", null); + + // set valid value again + cy.get('@input').clear().type('Administrator', {delay: 100}).blur(); + cy.wait('@validate_link'); + + cy.window() + .its("cur_frm.doc.assigned_by") + .should("eq", "Administrator"); + + // clear input + cy.get('@input').clear().blur(); + cy.get('.frappe-control[data-fieldname=assigned_by_full_name] .control-value').should( + 'contain', '' + ); + + cy.window() + .its("cur_frm.doc.assigned_by") + .should("eq", ""); }); }); + it("should set default values", () => { + cy.new_form("ToDo"); + cy.window() + .its("cur_frm") + .then(frm => { + frm.set_df_property("assigned_by", "default", "Administrator"); + cy.fill_field("description", "new", "Text Editor"); + cy.findByRole("button", {name: "Save"}).click(); + }); + }); + }); From bd8ac90286365b7ce550814e35dfdf9b3999bbfc Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 31 Jan 2022 18:57:32 +0530 Subject: [PATCH 060/151] fix(ux): show report button on too many writes error (#15614) * feat: log errors on too many writes to ease debugging * fix(ux): extract and show which app/file/function caused too many writes * fix: postgres error checking assumes exception is postgres exception * fix: better default for skipping frames Typically stack is like: some_app -> your_function -> this function. So last 2 frames need to be skipped. * fix: postgres error checking assumes exception is postgres exception * fix: better default for skipping frames Typically stack is like: some_app -> your_function -> this function. So last 2 frames need to be skipped. * refactor: show Report button instead of logging * revert: unnecessary new functionality * test: assert exact exception Co-authored-by: Ankush Menat --- frappe/database/database.py | 4 +++- frappe/database/postgres/database.py | 4 ++-- frappe/exceptions.py | 1 + frappe/tests/test_db.py | 12 ++++++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index 65242e0419..ab0a2abc72 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -278,7 +278,9 @@ class Database(object): if self.auto_commit_on_many_writes: self.commit() else: - frappe.throw(_("Too many writes in one request. Please send smaller requests"), frappe.ValidationError) + msg = "

" + _("Too many changes to database in single action.") + "
" + msg += _("The changes have been reverted.") + "
" + raise frappe.TooManyWritesError(msg) def check_implicit_commit(self, query): if self.transaction_writes and \ diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index d5495c6879..a3266242a5 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -170,11 +170,11 @@ class PostgresDatabase(Database): @staticmethod def is_primary_key_violation(e): - return e.pgcode == '23505' and '_pkey' in cstr(e.args[0]) + return getattr(e, "pgcode", None) == '23505' and '_pkey' in cstr(e.args[0]) @staticmethod def is_unique_key_violation(e): - return e.pgcode == '23505' and '_key' in cstr(e.args[0]) + return getattr(e, "pgcode", None) == '23505' and '_key' in cstr(e.args[0]) @staticmethod def is_duplicate_fieldname(e): diff --git a/frappe/exceptions.py b/frappe/exceptions.py index 8449425bc1..6ee72b5f81 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -103,6 +103,7 @@ class DocumentAlreadyRestored(ValidationError): pass class AttachmentLimitReached(ValidationError): pass class QueryTimeoutError(Exception): pass class QueryDeadlockError(Exception): pass +class TooManyWritesError(Exception): pass # OAuth exceptions class InvalidAuthorizationHeader(CSRFTokenError): pass class InvalidAuthorizationPrefix(CSRFTokenError): pass diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index cdef4354ed..885fe6ac26 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -294,6 +294,18 @@ class TestDB(unittest.TestCase): for d in created_docs: self.assertTrue(frappe.db.exists("ToDo", d)) + def test_transaction_writes_error(self): + from frappe.database.database import Database + frappe.db.rollback() + + frappe.db.MAX_WRITES_PER_TRANSACTION = 1 + note = frappe.get_last_doc("ToDo") + note.description = "changed" + with self.assertRaises(frappe.TooManyWritesError) as tmw: + note.save() + + frappe.db.MAX_WRITES_PER_TRANSACTION = Database.MAX_WRITES_PER_TRANSACTION + @run_only_if(db_type_is.MARIADB) class TestDDLCommandsMaria(unittest.TestCase): From da8c07d8fab34f83b7a20fc7d2a3978cb677b60d Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 31 Jan 2022 22:00:24 +0530 Subject: [PATCH 061/151] test: commented out lines in patches.txt (#15818) --- frappe/tests/test_patches.py | 49 +++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/frappe/tests/test_patches.py b/frappe/tests/test_patches.py index 64e8684f55..32e7b7ff3a 100644 --- a/frappe/tests/test_patches.py +++ b/frappe/tests/test_patches.py @@ -38,6 +38,16 @@ execute:frappe.function(arg="1") app.module.patch3 """ +COMMENTED_OUT = """ +[pre_model_sync] +app.module.patch1 +# app.module.patch2 # rerun +app.module.patch3 + +[post_model_sync] +app.module.patch4 +""" + class TestPatches(unittest.TestCase): def test_patch_module_names(self): frappe.flags.final_patches = [] @@ -70,50 +80,55 @@ class TestPatches(unittest.TestCase): class TestPatchReader(unittest.TestCase): + + def get_patches(self): + return ( + patch_handler.get_patches_from_app("frappe"), + patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.pre_model_sync), + patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.post_model_sync) + ) + @patch("builtins.open", new_callable=mock_open, read_data=EMTPY_FILE) def test_empty_file(self, _file): - all_patches = patch_handler.get_patches_from_app("frappe") - pre = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.pre_model_sync) - post = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.post_model_sync) - self.assertEqual(all_patches, []) + all, pre, post = self.get_patches() + self.assertEqual(all, []) self.assertEqual(pre, []) self.assertEqual(post, []) @patch("builtins.open", new_callable=mock_open, read_data=EMTPY_SECTION) def test_empty_sections(self, _file): - all_patches = patch_handler.get_patches_from_app("frappe") - pre = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.pre_model_sync) - post = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.post_model_sync) - self.assertEqual(all_patches, []) + all, pre, post = self.get_patches() + self.assertEqual(all, []) self.assertEqual(pre, []) self.assertEqual(post, []) @patch("builtins.open", new_callable=mock_open, read_data=FILLED_SECTIONS) def test_new_style(self, _file): - all_patches = patch_handler.get_patches_from_app("frappe") - pre = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.pre_model_sync) - post = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.post_model_sync) - self.assertEqual(all_patches, ["app.module.patch1", "app.module.patch2", "app.module.patch3"]) + all, pre, post = self.get_patches() + self.assertEqual(all, ["app.module.patch1", "app.module.patch2", "app.module.patch3"]) self.assertEqual(pre, ["app.module.patch1", "app.module.patch2"]) self.assertEqual(post, ["app.module.patch3",]) @patch("builtins.open", new_callable=mock_open, read_data=OLD_STYLE_PATCH_TXT) def test_old_style(self, _file): - all_patches = patch_handler.get_patches_from_app("frappe") - pre = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.pre_model_sync) - post = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.post_model_sync) - self.assertEqual(all_patches, ["app.module.patch1", "app.module.patch2", "app.module.patch3"]) + all, pre, post = self.get_patches() + self.assertEqual(all, ["app.module.patch1", "app.module.patch2", "app.module.patch3"]) self.assertEqual(pre, ["app.module.patch1", "app.module.patch2", "app.module.patch3"]) self.assertEqual(post, []) @patch("builtins.open", new_callable=mock_open, read_data=EDGE_CASES) def test_new_style_edge_cases(self, _file): - pre = patch_handler.get_patches_from_app("frappe", patch_handler.PatchType.pre_model_sync) + all, pre, post = self.get_patches() self.assertEqual(pre, [ "App.module.patch1", "app.module.patch2 # rerun", 'execute:frappe.db.updatedb("Item")', 'execute:frappe.function(arg="1")', ]) + + @patch("builtins.open", new_callable=mock_open, read_data=COMMENTED_OUT) + def test_ignore_comments(self, _file): + all, pre, post = self.get_patches() + self.assertEqual(pre, ["app.module.patch1", "app.module.patch3"]) From 7829de986204b8b6dfe35003e2fdf771347304e8 Mon Sep 17 00:00:00 2001 From: kamaljohnson Date: Tue, 1 Feb 2022 06:22:39 +0530 Subject: [PATCH 062/151] fix: auto creation of append to doctype ref while receiving mail --- .../doctype/email_account/email_account.py | 7 +++-- frappe/email/receive.py | 30 ++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index a05e20da24..7374fe861a 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -463,11 +463,11 @@ class EmailAccount(Document): """ mails = [] - def process_mail(messages): + def process_mail(messages, append_to=None): for index, message in enumerate(messages.get("latest_messages", [])): uid = messages['uid_list'][index] if messages.get('uid_list') else None seen_status = 1 if messages.get('seen_status', {}).get(uid) == 'SEEN' else 0 - mails.append(InboundMail(message, self, uid, seen_status)) + mails.append(InboundMail(message, self, uid, append_to, seen_status)) if frappe.local.flags.in_test: return [InboundMail(msg, self) for msg in test_mails or []] @@ -484,7 +484,8 @@ class EmailAccount(Document): email_server.select_imap_folder(folder.folder_name) email_server.settings['uid_validity'] = folder.uidvalidity messages = email_server.get_messages(folder=folder.folder_name) or {} - process_mail(messages) + append_to = folder.append_to + process_mail(messages, append_to) else: # process the pop3 account messages = email_server.get_messages() or {} diff --git a/frappe/email/receive.py b/frappe/email/receive.py index dd64d0df80..16119ae25e 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -582,10 +582,11 @@ class Email: class InboundMail(Email): """Class representation of incoming mail along with mail handlers. """ - def __init__(self, content, email_account, uid=None, seen_status=None): + def __init__(self, content, email_account, uid=None, append_to=None, seen_status=None): super().__init__(content) self.email_account = email_account self.uid = uid or -1 + self.append_to = append_to or -1 self.seen_status = seen_status or 0 # System documents related to this mail @@ -623,15 +624,24 @@ class InboundMail(Email): if self.parent_communication(): data['in_reply_to'] = self.parent_communication().name - if self.reference_document(): - data['reference_doctype'] = self.reference_document().doctype - data['reference_name'] = self.reference_document().name - elif self.email_account.append_to and self.email_account.append_to != 'Communication': - reference_doc = self._create_reference_document(self.email_account.append_to) - if reference_doc: - data['reference_doctype'] = reference_doc.doctype - data['reference_name'] = reference_doc.name - data['is_first'] = True + if self.email_account.use_imap and self.append_to: + if self.append_to != 'Communication': + reference_doc = self._create_reference_document(self.append_to) + if reference_doc: + data['reference_doctype'] = reference_doc.doctype + data['reference_name'] = reference_doc.name + data['is_first'] = True + else: + if self.reference_document(): + data['reference_doctype'] = self.reference_document().doctype + data['reference_name'] = self.reference_document().name + elif self.email_account.append_to and self.email_account.append_to != 'Communication': + reference_doc = self._create_reference_document(self.email_account.append_to) + # TODO: here instead of using email_account.append_to, the imap_folder.append_to should be used + if reference_doc: + data['reference_doctype'] = reference_doc.doctype + data['reference_name'] = reference_doc.name + data['is_first'] = True if self.is_notification(): # Disable notifications for notification. From 2f239dbe4ffb9f4477dd55de347b8eaecc6dc6fd Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 1 Feb 2022 10:16:24 +0530 Subject: [PATCH 063/151] fix: communication missing from form timeline --- frappe/desk/form/load.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 38b671d629..c03135b21a 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -91,8 +91,8 @@ def get_docinfo(doc=None, doctype=None, name=None): raise frappe.PermissionError all_communications = _get_communications(doc.doctype, doc.name) - automated_messages = filter(lambda x: x['communication_type'] == 'Automated Message', all_communications) - communications_except_auto_messages = filter(lambda x: x['communication_type'] != 'Automated Message', all_communications) + automated_messages = [msg for msg in all_communications if msg['communication_type'] == 'Automated Message'] + communications_except_auto_messages = [msg for msg in all_communications if msg['communication_type'] != 'Automated Message'] docinfo = frappe._dict(user_info = {}) From 57a6ee392a0fc94ac54188ffbf0f5943c7c859b0 Mon Sep 17 00:00:00 2001 From: kamaljohnson Date: Tue, 1 Feb 2022 11:00:50 +0530 Subject: [PATCH 064/151] fix: append_to parameter order in InboundMail --- frappe/email/doctype/email_account/email_account.py | 2 +- frappe/email/receive.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 7374fe861a..ee230d33f0 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -467,7 +467,7 @@ class EmailAccount(Document): for index, message in enumerate(messages.get("latest_messages", [])): uid = messages['uid_list'][index] if messages.get('uid_list') else None seen_status = 1 if messages.get('seen_status', {}).get(uid) == 'SEEN' else 0 - mails.append(InboundMail(message, self, uid, append_to, seen_status)) + mails.append(InboundMail(message, self, uid, seen_status, append_to)) if frappe.local.flags.in_test: return [InboundMail(msg, self) for msg in test_mails or []] diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 16119ae25e..cc4ec23e4e 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -582,7 +582,7 @@ class Email: class InboundMail(Email): """Class representation of incoming mail along with mail handlers. """ - def __init__(self, content, email_account, uid=None, append_to=None, seen_status=None): + def __init__(self, content, email_account, uid=None, seen_status=None, append_to=None): super().__init__(content) self.email_account = email_account self.uid = uid or -1 From 79f5a6b640c9672e2d123fd410daa1a434887a18 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 1 Feb 2022 11:18:35 +0530 Subject: [PATCH 065/151] refactor: get_docinfo without side effects --- frappe/desk/form/load.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index c03135b21a..3bc65cfd01 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -42,7 +42,8 @@ def getdoc(doctype, name, user=None): # add file list doc.add_viewed() - get_docinfo(doc) + frappe.response["docinfo"] = get_docinfo(doc) + except Exception: frappe.errprint(frappe.utils.get_traceback()) @@ -118,7 +119,7 @@ def get_docinfo(doc=None, doctype=None, name=None): update_user_info(docinfo) - frappe.response["docinfo"] = docinfo + return docinfo def add_comments(doc, docinfo): # divide comments into separate lists From 3af8debbadea5645c7a6839ef55d1ebb384e5c48 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 1 Feb 2022 11:37:07 +0530 Subject: [PATCH 066/151] test: docinfo --- frappe/tests/test_form_load.py | 48 ++++++++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/frappe/tests/test_form_load.py b/frappe/tests/test_form_load.py index d59e8f1570..b10d5eb796 100644 --- a/frappe/tests/test_form_load.py +++ b/frappe/tests/test_form_load.py @@ -1,9 +1,10 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import frappe, unittest -from frappe.desk.form.load import getdoctype, getdoc +from frappe.desk.form.load import getdoctype, getdoc, get_docinfo from frappe.core.page.permission_manager.permission_manager import update, reset, add from frappe.custom.doctype.property_setter.property_setter import make_property_setter +from frappe.utils.file_manager import save_file test_dependencies = ['Blog Category', 'Blogger'] @@ -141,9 +142,52 @@ class TestFormLoad(unittest.TestCase): contact.delete() + def test_get_doc_info(self): + note = frappe.new_doc("Note") + note.content = "some content" + note.title = frappe.generate_hash(length=20) + note.insert() + + note.content = "new content" + # trigger a version + note.save(ignore_version=False) + + note.add_comment(text="test") + + note.add_tag("test_tag") + note.add_tag("more_tag") + + # empty attachment + save_file("test_file", b"", note.doctype, note.name, decode=True) + + frappe.get_doc({ + "doctype": "Communication", + "communication_type": "Communication", + "content": "test email", + "reference_doctype": note.doctype, + "reference_name": note.name, + }).insert() + + + docinfo = get_docinfo(note) + + self.assertEqual(len(docinfo.comments), 1) + self.assertIn("test", docinfo.comments[0].content) + + self.assertGreaterEqual(len(docinfo.versions), 2) + + self.assertEqual(set(docinfo.tags.split(",")), {"more_tag", "test_tag"}) + + self.assertEqual(len(docinfo.attachments), 1) + self.assertIn("test_file", docinfo.attachments[0].file_name) + + self.assertEqual(len(docinfo.communications), 1) + self.assertIn("email", docinfo.communications[0].content) + note.delete() + def get_blog(blog_name): frappe.response.docs = [] getdoc('Blog Post', blog_name) doc = frappe.response.docs[0] - return doc \ No newline at end of file + return doc From 7ef9a2554c0185f6d4deb17827c17da555cec8e0 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Tue, 1 Feb 2022 12:53:02 +0530 Subject: [PATCH 067/151] test: web request with array values as params --- frappe/tests/test_client.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/frappe/tests/test_client.py b/frappe/tests/test_client.py index aed8dc8581..40639e4f98 100644 --- a/frappe/tests/test_client.py +++ b/frappe/tests/test_client.py @@ -101,3 +101,33 @@ class TestClient(unittest.TestCase): execute_cmd, frappe.local.form_dict.cmd ) + + def test_array_values_in_request_args(self): + import requests + from frappe.auth import CookieManager, LoginManager + + frappe.utils.set_request(path="/") + frappe.local.cookie_manager = CookieManager() + frappe.local.login_manager = LoginManager() + frappe.local.login_manager.login_as('Administrator') + params = { + 'doctype': 'DocType', + 'fields': ['name', 'modified'], + 'sid': frappe.session.sid, + } + headers = { + 'accept': 'application/json', + 'content-type': 'application/json', + } + url = f'http://{frappe.local.site}:{frappe.conf.webserver_port}/api/method/frappe.client.get_list' + res = requests.post( + url, + json=params, + headers=headers + ) + self.assertEqual(res.status_code, 200) + data = res.json() + first_item = data['message'][0] + self.assertTrue('name' in first_item) + self.assertTrue('modified' in first_item) + frappe.local.login_manager.logout() From ebd756c1e54ee2d988d995aa4c7e80373481259b Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 1 Feb 2022 13:30:48 +0530 Subject: [PATCH 068/151] test: Update test case for default value in link field --- cypress/integration/control_link.js | 33 ++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index 13ad52d237..bfa70ad338 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -142,14 +142,31 @@ context('Control Link', () => { }); it("should set default values", () => { + cy.insert_doc("Property Setter", { + "doctype_or_field": "DocField", + "doc_type": "ToDo", + "field_name": "assigned_by", + "property": "default", + "property_type": "Text", + "value": "Administrator" + }, true); + cy.reload(); cy.new_form("ToDo"); - cy.window() - .its("cur_frm") - .then(frm => { - frm.set_df_property("assigned_by", "default", "Administrator"); - cy.fill_field("description", "new", "Text Editor"); - cy.findByRole("button", {name: "Save"}).click(); - }); + cy.fill_field("description", "new", "Text Editor"); + cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form"); + cy.findByRole("button", {name: "Save"}).click(); + cy.wait("@save_form"); + cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should( + "contain", "Administrator" + ); + // if user clears default value explicitly, system should not reset default again + cy.get_field("assigned_by").clear().blur(); + cy.intercept("POST", "/api/method/frappe.desk.form.save.savedocs").as("save_form"); + cy.findByRole("button", {name: "Save"}).click(); + cy.wait("@save_form"); + cy.get_field("assigned_by").should("have.value", ""); + cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should( + "contain", "" + ); }); - }); From 8998d87fc4b96ba00917b5d63dad45320ef3b4ee Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 1 Feb 2022 13:36:17 +0530 Subject: [PATCH 069/151] fix: Update set_nulls code to filters out empty values for link fields - Empty values for link field is invalid and causes issue mentioned here https://github.com/frappe/frappe/pull/15320 --- frappe/public/js/frappe/form/controls/link.js | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index 7f671c6e8b..63785e551f 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -374,13 +374,26 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat } set_custom_query(args) { - var set_nulls = function(obj) { - $.each(obj, function(key, value) { - if(value!==undefined) { - obj[key] = value; + const is_valid_value = (value, key) => { + if (value) return true; + // check if empty value is valid + if (this.frm) { + let field = frappe.meta.get_docfield(this.frm.doctype, key); + // empty value link fields is invalid + return !field || !["Link", "Dynamic Link"].includes(field.fieldtype); + } else { + return value !== undefined; + } + } + + const set_nulls = (obj) => { + let new_obj = {} + $.each(obj, (key, value) => { + if (is_valid_value(value, key)) { + new_obj[key] = value; } }); - return obj; + return new_obj; }; if(this.get_query || this.df.get_query) { var get_query = this.get_query || this.df.get_query; From e18f8d6e1075cfc35a2a304a63f2572b54166f8b Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 1 Feb 2022 13:37:46 +0530 Subject: [PATCH 070/151] chore: Remove duplicate command --- cypress/support/commands.js | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 758b3cde2b..4f273af21f 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -110,34 +110,6 @@ Cypress.Commands.add('get_doc', (doctype, name) => { }); }); -Cypress.Commands.add('insert_doc', (doctype, args, ignore_duplicate) => { - return cy - .window() - .its('frappe.csrf_token') - .then(csrf_token => { - return cy - .request({ - method: 'POST', - url: `/api/resource/${doctype}`, - body: args, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-Frappe-CSRF-Token': csrf_token - }, - failOnStatusCode: !ignore_duplicate - }) - .then(res => { - let status_codes = [200]; - if (ignore_duplicate) { - status_codes.push(409); - } - expect(res.status).to.be.oneOf(status_codes); - return res.body; - }); - }); -}); - Cypress.Commands.add('remove_doc', (doctype, name) => { return cy .window() From c88292a6ae40c3f8426b62aff2bbe752b66105f2 Mon Sep 17 00:00:00 2001 From: hrwx Date: Tue, 1 Feb 2022 08:26:24 +0000 Subject: [PATCH 071/151] fix: trigger form attach on callback --- .../js/frappe/file_uploader/FileUploader.vue | 9 +++--- .../js/integrations/google_drive_picker.js | 29 ++++++++++++++++--- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/frappe/public/js/frappe/file_uploader/FileUploader.vue b/frappe/public/js/frappe/file_uploader/FileUploader.vue index 167b4955fa..1b30726a7a 100644 --- a/frappe/public/js/frappe/file_uploader/FileUploader.vue +++ b/frappe/public/js/frappe/file_uploader/FileUploader.vue @@ -534,22 +534,21 @@ export default { }); }, show_google_drive_picker() { - let dialog = cur_dialog; - dialog.hide(); + this.close_dialog = true; let google_drive = new GoogleDrivePicker({ - pickerCallback: data => this.google_drive_callback(data, dialog), + pickerCallback: data => this.google_drive_callback(data), ...this.google_drive_settings }); google_drive.loadPicker(); }, - google_drive_callback(data, dialog) { + google_drive_callback(data) { if (data.action == google.picker.Action.PICKED) { this.upload_file({ file_url: data.docs[0].url, file_name: data.docs[0].name }); } else if (data.action == google.picker.Action.CANCEL) { - dialog.show(); + cur_frm.attachments.new_attachment() } }, url_to_file(url, filename, mime_type) { diff --git a/frappe/public/js/integrations/google_drive_picker.js b/frappe/public/js/integrations/google_drive_picker.js index 9d7971e75c..1e4f1dca7c 100644 --- a/frappe/public/js/integrations/google_drive_picker.js +++ b/frappe/public/js/integrations/google_drive_picker.js @@ -44,9 +44,16 @@ export default class GoogleDrivePicker { } handleAuthResult(authResult) { + let error_map = { + "popup_closed_by_user": __("Google Authentication was closed abruptly by the user") + }; + if (authResult && !authResult.error) { frappe.boot.user.google_drive_token = authResult.access_token; this.createPicker(); + } else { + let error = error_map[authResult.error] || __("Google Authentication Error"); + frappe.throw(error); } } @@ -58,20 +65,34 @@ export default class GoogleDrivePicker { createPicker() { // Create and render a Picker object for searching images. if (this.pickerApiLoaded && frappe.boot.user.google_drive_token) { - var view = new google.picker.DocsView(google.picker.ViewId.DOCS) + this.view = new google.picker.DocsView(google.picker.ViewId.DOCS) .setParent('root') // show the root folder by default .setIncludeFolders(true); // also show folders, not just files - var picker = new google.picker.PickerBuilder() + this.picker = new google.picker.PickerBuilder() .setAppId(this.appId) .setDeveloperKey(this.developerKey) .setOAuthToken(frappe.boot.user.google_drive_token) - .addView(view) + .addView(this.view) .setLocale(frappe.boot.lang) .setCallback(this.pickerCallback) .build(); - picker.setVisible(true); + this.picker.setVisible(true); + this.setupHide(); + } + } + + setupHide() { + let bg = $(".picker-dialog-bg"); + + for (let el of bg) { + el.onclick = () => { + this.picker.setVisible(false); + this.picker.Ob({ + action: google.picker.Action.CANCEL + }); + }; } } } From d2436b88c5c94733c36af03158bd93249ace2b50 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 1 Feb 2022 14:37:39 +0530 Subject: [PATCH 072/151] fix: Update filter whilte removing undefined --- frappe/public/js/frappe/form/controls/link.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/link.js b/frappe/public/js/frappe/form/controls/link.js index 63785e551f..9f02485a9e 100644 --- a/frappe/public/js/frappe/form/controls/link.js +++ b/frappe/public/js/frappe/form/controls/link.js @@ -387,13 +387,12 @@ frappe.ui.form.ControlLink = class ControlLink extends frappe.ui.form.ControlDat } const set_nulls = (obj) => { - let new_obj = {} $.each(obj, (key, value) => { - if (is_valid_value(value, key)) { - new_obj[key] = value; + if (!is_valid_value(value, key)) { + delete obj[key]; } }); - return new_obj; + return obj; }; if(this.get_query || this.df.get_query) { var get_query = this.get_query || this.df.get_query; From af5e9099dcdd4975b9ec0075bac58e7856e157b9 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Tue, 1 Feb 2022 16:47:30 +0530 Subject: [PATCH 073/151] fix: Doctype name which is tablename in database is limited to 64 characters --- frappe/core/doctype/doctype/doctype.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 3754288145..a439458ec7 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -699,6 +699,12 @@ class DocType(Document): if not name: name = self.name + # a Doctype name is the tablename created in database + # `tab` the length of tablename is limited to 64 characters + if len(name) > (frappe.db.MAX_COLUMN_LENGTH - 3): + # length(tab + ) should be equal to 64 characters hence doctype should be 61 characters + frappe.throw(_("Doctype name is limited to 61 characters ({0})").format(name)) + flags = {"flags": re.ASCII} # a DocType name should not start or end with an empty space From 8c923820da107dc93520360a6f1ce787acaff433 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 21 Dec 2021 22:09:01 +0530 Subject: [PATCH 074/151] fix: Ability to continue partially processed data imports --- .../core/doctype/data_import/data_import.js | 21 +++++++++++++++++-- .../core/doctype/data_import/data_import.py | 4 ++-- .../doctype/data_import/data_import_list.js | 8 +++++++ frappe/core/doctype/data_import/importer.py | 14 ++++++++++--- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index 216db53c72..3838439dcf 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -109,8 +109,18 @@ frappe.ui.form.on('Data Import', { frm.disable_save(); if (frm.doc.status !== 'Success') { if (!frm.is_new() && (frm.has_import_file())) { - let label = - frm.doc.status === 'Pending' ? __('Start Import') : __('Retry'); + let label = '' + if (frm.doc.status === 'Pending') { + let import_log = JSON.parse(frm.doc.import_log || '[]'); + if (import_log) { + label = __('Continue Import') + } else { + label = __('Start Import') + } + } else { + label = __('Retry') + } + frm.page.set_primary_action(label, () => frm.events.start_import(frm)); } else { frm.page.set_primary_action(__('Save'), () => frm.save()); @@ -133,6 +143,13 @@ frappe.ui.form.on('Data Import', { let failed_records = import_log.filter(log => !log.success); if (successful_records.length === 0) return; + if (frm.doc.status == 'Pending' && import_log) { + frm.page.set_indicator(__('Partially Completed'), 'orange'); + + // Do not allow changing file for partially completed imports + frm.set_df_property('import_file', 'read_only', 1); + } + let message; if (failed_records.length === 0) { let message_args = [successful_records.length]; diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index 5935ddc4ba..20d116d0ae 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -66,8 +66,8 @@ class DataImport(Document): if self.name not in enqueued_jobs: enqueue( start_import, - queue="default", - timeout=6000, + queue="long", + timeout=10000, event="data_import", job_name=self.name, data_import=self.name, diff --git a/frappe/core/doctype/data_import/data_import_list.js b/frappe/core/doctype/data_import/data_import_list.js index 0eb05aa354..d1701da15b 100644 --- a/frappe/core/doctype/data_import/data_import_list.js +++ b/frappe/core/doctype/data_import/data_import_list.js @@ -1,6 +1,7 @@ let imports_in_progress = []; frappe.listview_settings['Data Import'] = { + add_fields: ["import_log"], onload(listview) { frappe.realtime.on('data_import_progress', data => { if (!imports_in_progress.includes(data.data_import)) { @@ -19,17 +20,24 @@ frappe.listview_settings['Data Import'] = { 'Pending': 'orange', 'Not Started': 'orange', 'Partial Success': 'orange', + 'Partial Completed': 'orange', 'Success': 'green', 'In Progress': 'orange', 'Error': 'red' }; let status = doc.status; + let import_log = JSON.parse(doc.import_log || '[]'); + if (imports_in_progress.includes(doc.name)) { status = 'In Progress'; } if (status == 'Pending') { status = 'Not Started'; } + if (doc.status == 'Pending' && import_log.length > 0) { + status = 'Partially Completed' + } + return [__(status), colors[status], 'status,=,' + doc.status]; }, formatters: { diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index b9b2050763..2b385dc4b0 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -84,14 +84,16 @@ class Importer: else: import_log = [] - # remove previous failures from import log - import_log = [log for log in import_log if log.get("success")] + # Do not remove rows in case of retry after an error or pending data import + if self.data_import.status == 'Partial Success': + # remove previous failures from import log only in case of retry after partial success + import_log = [log for log in import_log if log.get("success")] # get successfully imported rows imported_rows = [] for log in import_log: log = frappe._dict(log) - if log.success: + if log.success or self.data_import.status == 'Pending': imported_rows += log.row_indexes # start import @@ -149,6 +151,12 @@ class Importer: import_log.append( frappe._dict(success=True, docname=doc.name, row_indexes=row_indexes) ) + + # Update import log after every successful import + # This is done for cases where the background job might get terminated due to timeout + # In such cases the job can be rerun only for pending rows by referring to import logs + self.data_import.db_set("import_log", json.dumps(import_log)) + # commit after every successful import frappe.db.commit() From 866496437a952b9a880ca1fc00fa5650c5bbac2b Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 22 Dec 2021 11:15:39 +0530 Subject: [PATCH 075/151] fix: Linting issues --- frappe/core/doctype/data_import/data_import.js | 10 +++++----- frappe/core/doctype/data_import/data_import_list.js | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index 3838439dcf..15f76a693e 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -109,16 +109,16 @@ frappe.ui.form.on('Data Import', { frm.disable_save(); if (frm.doc.status !== 'Success') { if (!frm.is_new() && (frm.has_import_file())) { - let label = '' + let label = ''; if (frm.doc.status === 'Pending') { let import_log = JSON.parse(frm.doc.import_log || '[]'); - if (import_log) { - label = __('Continue Import') + if (import_log.length > 0) { + label = __('Continue Import'); } else { - label = __('Start Import') + label = __('Start Import'); } } else { - label = __('Retry') + label = __('Retry'); } frm.page.set_primary_action(label, () => frm.events.start_import(frm)); diff --git a/frappe/core/doctype/data_import/data_import_list.js b/frappe/core/doctype/data_import/data_import_list.js index d1701da15b..c9526d8f30 100644 --- a/frappe/core/doctype/data_import/data_import_list.js +++ b/frappe/core/doctype/data_import/data_import_list.js @@ -35,7 +35,7 @@ frappe.listview_settings['Data Import'] = { status = 'Not Started'; } if (doc.status == 'Pending' && import_log.length > 0) { - status = 'Partially Completed' + status = 'Partially Completed'; } return [__(status), colors[status], 'status,=,' + doc.status]; From 9db540a675daba75708d51643978cfdb3334a1fb Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 23 Dec 2021 11:48:03 +0530 Subject: [PATCH 076/151] fix: Add validation for file change --- frappe/core/doctype/data_import/data_import.py | 6 +++++- frappe/core/doctype/data_import/data_import_list.js | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index 20d116d0ae..2545e343c9 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -29,6 +29,10 @@ class DataImport(Document): self.validate_google_sheets_url() def validate_import_file(self): + + if self.status == 'Pending' and self.import_log: + frappe.throw(_("File cannot be changed for partially completed imports. Either continue import or make a fresh import")) + if self.import_file: # validate template self.get_importer() @@ -66,7 +70,7 @@ class DataImport(Document): if self.name not in enqueued_jobs: enqueue( start_import, - queue="long", + queue="default", timeout=10000, event="data_import", job_name=self.name, diff --git a/frappe/core/doctype/data_import/data_import_list.js b/frappe/core/doctype/data_import/data_import_list.js index c9526d8f30..dd40e43973 100644 --- a/frappe/core/doctype/data_import/data_import_list.js +++ b/frappe/core/doctype/data_import/data_import_list.js @@ -20,7 +20,7 @@ frappe.listview_settings['Data Import'] = { 'Pending': 'orange', 'Not Started': 'orange', 'Partial Success': 'orange', - 'Partial Completed': 'orange', + 'Partially Completed': 'orange', 'Success': 'green', 'In Progress': 'orange', 'Error': 'red' From f507011eda3863174ca18f6572685eb80c570996 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 23 Dec 2021 18:56:36 +0530 Subject: [PATCH 077/151] fix: Use long queue for data imports --- frappe/core/doctype/data_import/data_import.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index 2545e343c9..5e43b1e6e5 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -70,7 +70,7 @@ class DataImport(Document): if self.name not in enqueued_jobs: enqueue( start_import, - queue="default", + queue="long", timeout=10000, event="data_import", job_name=self.name, From 1c6d911718ab05967d6a2b0b5ba8727bcfee176b Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 23 Dec 2021 21:19:08 +0530 Subject: [PATCH 078/151] fix: Ability to export import logs --- .../core/doctype/data_import/data_import.js | 18 ++++++++++++++++- .../core/doctype/data_import/data_import.py | 10 +++++++++- frappe/core/doctype/data_import/importer.py | 20 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index 15f76a693e..1ee5b1efd2 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -333,6 +333,15 @@ frappe.ui.form.on('Data Import', { ); }, + export_import_log(frm) { + open_url_post( + '/api/method/frappe.core.doctype.data_import.data_import.download_import_log', + { + data_import_name: frm.doc.name + } + ); + }, + show_import_warnings(frm, preview_data) { let columns = preview_data.columns; let warnings = JSON.parse(frm.doc.template_warnings || '[]'); @@ -419,7 +428,9 @@ frappe.ui.form.on('Data Import', { return; } - let rows = logs + // Show import logs only if rows are less than 5000 + if (logs.length < 5000) { + let rows = logs .map(log => { let html = ''; if (log.success) { @@ -495,5 +506,10 @@ frappe.ui.form.on('Data Import', { ${rows} `); + } else if (logs.length > 0) { + frm.add_custom_button(__('Export Import Log'), () => + frm.trigger('export_import_log') + ); + } }, }); diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index 5e43b1e6e5..fb6c5b147a 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -70,7 +70,7 @@ class DataImport(Document): if self.name not in enqueued_jobs: enqueue( start_import, - queue="long", + queue="default", timeout=10000, event="data_import", job_name=self.name, @@ -84,6 +84,9 @@ class DataImport(Document): def export_errored_rows(self): return self.get_importer().export_errored_rows() + def download_import_log(self): + return self.get_importer().export_import_log() + def get_importer(self): return Importer(self.reference_doctype, data_import=self) @@ -149,6 +152,11 @@ def download_errored_template(data_import_name): data_import = frappe.get_doc("Data Import", data_import_name) data_import.export_errored_rows() +@frappe.whitelist() +def download_import_log(data_import_name): + data_import = frappe.get_doc("Data Import", data_import_name) + data_import.download_import_log() + def import_file( doctype, file_path, import_type, submit_after_import=False, console=False diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 2b385dc4b0..706c40c910 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -272,6 +272,26 @@ class Importer: build_csv_response(rows, _(self.doctype)) + def export_import_log(self): + from frappe.utils.csvutils import build_csv_response + + if not self.data_import: + return + import_log = frappe.parse_json(self.data_import.import_log or "[]") + header_row = ["Row Numbers", "Status", "Message", "Exception"] + + rows = [header_row] + + for log in import_log: + row_number = log.get("row_indexes")[0] + status = "Success" if log.get('success') else "Failure" + message = "Successfully Imported {0}".format(log.get('docname')) if log.get('success') else \ + log.get("messages") + exception = frappe.utils.cstr(log.get("exception", '')) + rows += [[row_number, status, message, exception]] + + build_csv_response(rows, self.doctype) + def print_import_log(self, import_log): failed_records = [log for log in import_log if not log.success] successful_records = [log for log in import_log if log.success] From 33feee7ae29aa36c99898c45910351c4bfc12fa4 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 24 Dec 2021 11:38:15 +0530 Subject: [PATCH 079/151] fix: Toggle Import section --- frappe/core/doctype/data_import/data_import.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index 1ee5b1efd2..2a8df60b9e 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -507,6 +507,8 @@ frappe.ui.form.on('Data Import', { `); } else if (logs.length > 0) { + frm.toggle_display('import_log_section', false); + frm.add_custom_button(__('Export Import Log'), () => frm.trigger('export_import_log') ); From fc2f7fa028a3feca62d814d2131b2a021a45125d Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 24 Dec 2021 16:27:09 +0530 Subject: [PATCH 080/151] fix: List view status --- frappe/core/doctype/data_import/data_import_list.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import_list.js b/frappe/core/doctype/data_import/data_import_list.js index dd40e43973..e7ddbf32f4 100644 --- a/frappe/core/doctype/data_import/data_import_list.js +++ b/frappe/core/doctype/data_import/data_import_list.js @@ -26,7 +26,6 @@ frappe.listview_settings['Data Import'] = { 'Error': 'red' }; let status = doc.status; - let import_log = JSON.parse(doc.import_log || '[]'); if (imports_in_progress.includes(doc.name)) { status = 'In Progress'; @@ -34,9 +33,6 @@ frappe.listview_settings['Data Import'] = { if (status == 'Pending') { status = 'Not Started'; } - if (doc.status == 'Pending' && import_log.length > 0) { - status = 'Partially Completed'; - } return [__(status), colors[status], 'status,=,' + doc.status]; }, From d99b99cb7bb822f9ec8c8f44e928da1c150e5172 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Sat, 25 Dec 2021 09:16:07 +0530 Subject: [PATCH 081/151] fix: Do not pull import logs in list view --- frappe/core/doctype/data_import/data_import_list.js | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/core/doctype/data_import/data_import_list.js b/frappe/core/doctype/data_import/data_import_list.js index e7ddbf32f4..84b0f0ada1 100644 --- a/frappe/core/doctype/data_import/data_import_list.js +++ b/frappe/core/doctype/data_import/data_import_list.js @@ -1,7 +1,6 @@ let imports_in_progress = []; frappe.listview_settings['Data Import'] = { - add_fields: ["import_log"], onload(listview) { frappe.realtime.on('data_import_progress', data => { if (!imports_in_progress.includes(data.data_import)) { From 764878fe76d0b14d5dd8fe1aee8b76eb32c60881 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 27 Dec 2021 12:20:14 +0530 Subject: [PATCH 082/151] chore: Add separate doctype for Data Import Log --- .../core/doctype/data_import_log/__init__.py | 0 .../data_import_log/data_import_log.js | 8 ++ .../data_import_log/data_import_log.json | 84 +++++++++++++++++++ .../data_import_log/data_import_log.py | 8 ++ .../data_import_log/test_data_import_log.py | 8 ++ 5 files changed, 108 insertions(+) create mode 100644 frappe/core/doctype/data_import_log/__init__.py create mode 100644 frappe/core/doctype/data_import_log/data_import_log.js create mode 100644 frappe/core/doctype/data_import_log/data_import_log.json create mode 100644 frappe/core/doctype/data_import_log/data_import_log.py create mode 100644 frappe/core/doctype/data_import_log/test_data_import_log.py diff --git a/frappe/core/doctype/data_import_log/__init__.py b/frappe/core/doctype/data_import_log/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/data_import_log/data_import_log.js b/frappe/core/doctype/data_import_log/data_import_log.js new file mode 100644 index 0000000000..c376edeec9 --- /dev/null +++ b/frappe/core/doctype/data_import_log/data_import_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Data Import Log', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/data_import_log/data_import_log.json b/frappe/core/doctype/data_import_log/data_import_log.json new file mode 100644 index 0000000000..a39eb9be1d --- /dev/null +++ b/frappe/core/doctype/data_import_log/data_import_log.json @@ -0,0 +1,84 @@ +{ + "actions": [], + "creation": "2021-12-25 16:12:20.205889", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "data_import", + "row_indexes", + "success", + "docname", + "messages", + "exception", + "log_index" + ], + "fields": [ + { + "fieldname": "data_import", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Data Import", + "options": "Data Import" + }, + { + "fieldname": "docname", + "fieldtype": "Data", + "label": "Reference Name" + }, + { + "fieldname": "exception", + "fieldtype": "Text", + "label": "Exception" + }, + { + "fieldname": "row_indexes", + "fieldtype": "Code", + "label": "Row Indexes", + "options": "JSON" + }, + { + "default": "0", + "fieldname": "success", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Success" + }, + { + "fieldname": "log_index", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Log Index" + }, + { + "fieldname": "messages", + "fieldtype": "Code", + "label": "Messages", + "options": "JSON" + } + ], + "in_create": 1, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-12-27 11:19:19.646076", + "modified_by": "Administrator", + "module": "Core", + "name": "Data Import Log", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/frappe/core/doctype/data_import_log/data_import_log.py b/frappe/core/doctype/data_import_log/data_import_log.py new file mode 100644 index 0000000000..a71aefa8bc --- /dev/null +++ b/frappe/core/doctype/data_import_log/data_import_log.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class DataImportLog(Document): + pass diff --git a/frappe/core/doctype/data_import_log/test_data_import_log.py b/frappe/core/doctype/data_import_log/test_data_import_log.py new file mode 100644 index 0000000000..244404936e --- /dev/null +++ b/frappe/core/doctype/data_import_log/test_data_import_log.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies and Contributors +# See license.txt + +# import frappe +import unittest + +class TestDataImportLog(unittest.TestCase): + pass From d79abcf84c2774f44d7a90d8d937862239287a68 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 27 Dec 2021 12:20:40 +0530 Subject: [PATCH 083/151] fix: Changes required for data import log --- .../core/doctype/data_import/data_import.js | 309 ++++++++++-------- .../core/doctype/data_import/data_import.json | 13 +- .../core/doctype/data_import/data_import.py | 33 +- frappe/core/doctype/data_import/importer.py | 77 +++-- .../js/frappe/data_import/import_preview.js | 2 +- 5 files changed, 248 insertions(+), 186 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index 2a8df60b9e..e395a199b8 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -44,6 +44,7 @@ frappe.ui.form.on('Data Import', { } frm.dashboard.show_progress(__('Import Progress'), percent, message); frm.page.set_indicator(__('In Progress'), 'orange'); + frm.trigger('update_primary_action'); // hide progress when complete if (data.current === data.total) { @@ -109,19 +110,16 @@ frappe.ui.form.on('Data Import', { frm.disable_save(); if (frm.doc.status !== 'Success') { if (!frm.is_new() && (frm.has_import_file())) { - let label = ''; - if (frm.doc.status === 'Pending') { - let import_log = JSON.parse(frm.doc.import_log || '[]'); - if (import_log.length > 0) { - label = __('Continue Import'); - } else { - label = __('Start Import'); - } - } else { - label = __('Retry'); + let label = + frm.doc.status === 'Pending' ? __('Start Import') : __('Retry'); + + if (frm.doc.status == 'Partially Completed') { + label = __('Continue Import'); } - frm.page.set_primary_action(label, () => frm.events.start_import(frm)); + if (!frm.import_in_progress) { + frm.page.set_primary_action(label, () => frm.events.start_import(frm)); + } } else { frm.page.set_primary_action(__('Save'), () => frm.save()); } @@ -138,47 +136,49 @@ frappe.ui.form.on('Data Import', { }, show_import_status(frm) { - let import_log = JSON.parse(frm.doc.import_log || '[]'); - let successful_records = import_log.filter(log => log.success); - let failed_records = import_log.filter(log => !log.success); - if (successful_records.length === 0) return; + frappe.call({ + 'method': 'frappe.core.doctype.data_import.data_import.get_import_status', + 'args': { + 'data_import_name': frm.doc.name + }, + 'callback': function(r) { + let successful_records = cint(r.message.success); + let failed_records = cint(r.message.failed); + let total_records = cint(r.message.total_records); - if (frm.doc.status == 'Pending' && import_log) { - frm.page.set_indicator(__('Partially Completed'), 'orange'); - - // Do not allow changing file for partially completed imports - frm.set_df_property('import_file', 'read_only', 1); - } + if (!total_records) return - let message; - if (failed_records.length === 0) { - let message_args = [successful_records.length]; - if (frm.doc.import_type === 'Insert New Records') { - message = - successful_records.length > 1 - ? __('Successfully imported {0} records.', message_args) - : __('Successfully imported {0} record.', message_args); - } else { - message = - successful_records.length > 1 - ? __('Successfully updated {0} records.', message_args) - : __('Successfully updated {0} record.', message_args); + let message; + if (failed_records === 0) { + let message_args = [successful_records]; + if (me.frm.doc.import_type === 'Insert New Records') { + message = + successful_records > 1 + ? __('Successfully imported {0} records.', message_args) + : __('Successfully imported {0} record.', message_args); + } else { + message = + successful_records > 1 + ? __('Successfully updated {0} records.', message_args) + : __('Successfully updated {0} record.', message_args); + } + } else { + let message_args = [successful_records, total_records]; + if (frm.doc.import_type === 'Insert New Records') { + message = + successful_records > 1 + ? __('Successfully imported {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args) + : __('Successfully imported {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args); + } else { + message = + successful_records> 1 + ? __('Successfully updated {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args) + : __('Successfully updated {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args); + } + } + frm.dashboard.set_headline(message); } - } else { - let message_args = [successful_records.length, import_log.length]; - if (frm.doc.import_type === 'Insert New Records') { - message = - successful_records.length > 1 - ? __('Successfully imported {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args) - : __('Successfully imported {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args); - } else { - message = - successful_records.length > 1 - ? __('Successfully updated {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args) - : __('Successfully updated {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.', message_args); - } - } - frm.dashboard.set_headline(message); + }); }, show_report_error_button(frm) { @@ -285,15 +285,14 @@ frappe.ui.form.on('Data Import', { } }) .then(r => { - let preview_data = r.message; - frm.events.show_import_preview(frm, preview_data); + let preview_data = r.message.preview_data; + let import_log = r.message.import_log; + frm.events.show_import_preview(frm, preview_data, import_log); frm.events.show_import_warnings(frm, preview_data); }); }, - show_import_preview(frm, preview_data) { - let import_log = JSON.parse(frm.doc.import_log || '[]'); - + show_import_preview(frm, preview_data, import_log) { if ( frm.import_preview && frm.import_preview.doctype === frm.doc.reference_doctype @@ -417,101 +416,123 @@ frappe.ui.form.on('Data Import', { frm.trigger('show_import_log'); }, - show_import_log(frm) { - let import_log = JSON.parse(frm.doc.import_log || '[]'); - let logs = import_log; - frm.toggle_display('import_log', false); - frm.toggle_display('import_log_section', logs.length > 0); + render_import_log(frm) { + frappe.call({ + 'method': 'frappe.client.get_list', + 'args': { + 'doctype': 'Data Import Log', + 'filters': { + 'data_import': frm.doc.name + }, + 'fields': ['success', 'docname', 'messages', 'exception', 'row_indexes'], + 'page_limit_length': 5000 + }, + callback: function(r) { + let logs = r.message + let rows = logs + .map(log => { + let html = ''; + if (log.success) { + if (frm.doc.import_type === 'Insert New Records') { + html = __('Successfully imported {0}', [ + `${frappe.utils.get_form_link( + frm.doc.reference_doctype, + log.docname, + true + )}` + ]); + } else { + html = __('Successfully updated {0}', [ + `${frappe.utils.get_form_link( + frm.doc.reference_doctype, + log.docname, + true + )}` + ]); + } + } else { + let messages = (log.messages || []) + .map(JSON.parse) + .map(m => { + let title = m.title ? `${m.title}` : ''; + let message = m.message ? `
${m.message}
` : ''; + return title + message; + }) + .join(''); + let id = frappe.dom.get_unique_id(); + html = `${messages} + +
+
+
${log.exception}
+
+
`; + } + let indicator_color = log.success ? 'green' : 'red'; + let title = log.success ? __('Success') : __('Failure'); - if (logs.length === 0) { - frm.get_field('import_log_preview').$wrapper.empty(); + if (frm.doc.show_failed_logs && log.success) { + return ''; + } + + return ` + ${JSON.parse(log.row_indexes).join(', ')} + +
${title}
+ + + ${html} + + `; + }) + .join(''); + + if (!rows && frm.doc.show_failed_logs) { + rows = ` + ${__('No failed logs')} + `; + } + + frm.get_field('import_log_preview').$wrapper.html(` + + + + + + + ${rows} +
${__('Row Number')}${__('Status')}${__('Message')}
+ `); + } + }); + }, + + show_import_log(frm) { + if (frm.import_in_progress) { return; } - // Show import logs only if rows are less than 5000 - if (logs.length < 5000) { - let rows = logs - .map(log => { - let html = ''; - if (log.success) { - if (frm.doc.import_type === 'Insert New Records') { - html = __('Successfully imported {0}', [ - `${frappe.utils.get_form_link( - frm.doc.reference_doctype, - log.docname, - true - )}` - ]); - } else { - html = __('Successfully updated {0}', [ - `${frappe.utils.get_form_link( - frm.doc.reference_doctype, - log.docname, - true - )}` - ]); - } + frappe.call({ + 'method': 'frappe.client.get_count', + 'args': { + 'doctype': 'Data Import Log', + 'filters': { + 'data_import': frm.doc.name + } + }, + 'callback': function(r) { + let count = r.message; + if(count < 5000) { + frm.trigger('render_import_log'); } else { - let messages = log.messages - .map(JSON.parse) - .map(m => { - let title = m.title ? `${m.title}` : ''; - let message = m.message ? `
${m.message}
` : ''; - return title + message; - }) - .join(''); - let id = frappe.dom.get_unique_id(); - html = `${messages} - -
-
-
${log.exception}
-
-
`; + frm.toggle_display('import_log_section', false); + frm.add_custom_button(__('Export Import Log'), () => + frm.trigger('export_import_log') + ); } - let indicator_color = log.success ? 'green' : 'red'; - let title = log.success ? __('Success') : __('Failure'); - - if (frm.doc.show_failed_logs && log.success) { - return ''; - } - - return ` - ${log.row_indexes.join(', ')} - -
${title}
- - - ${html} - - `; - }) - .join(''); - - if (!rows && frm.doc.show_failed_logs) { - rows = ` - ${__('No failed logs')} - `; - } - - frm.get_field('import_log_preview').$wrapper.html(` - - - - - - - ${rows} -
${__('Row Number')}${__('Status')}${__('Message')}
- `); - } else if (logs.length > 0) { - frm.toggle_display('import_log_section', false); - - frm.add_custom_button(__('Export Import Log'), () => - frm.trigger('export_import_log') - ); - } + } + }); }, }); diff --git a/frappe/core/doctype/data_import/data_import.json b/frappe/core/doctype/data_import/data_import.json index fe6fb90481..997c14cf7f 100644 --- a/frappe/core/doctype/data_import/data_import.json +++ b/frappe/core/doctype/data_import/data_import.json @@ -25,7 +25,6 @@ "section_import_preview", "import_preview", "import_log_section", - "import_log", "show_failed_logs", "import_log_preview" ], @@ -54,7 +53,7 @@ "fieldtype": "Attach", "in_list_view": 1, "label": "Import File", - "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)" + "read_only_depends_on": "eval: ['Success', 'Partial Success', 'Partially Completed'].includes(doc.status)" }, { "fieldname": "import_preview", @@ -78,12 +77,6 @@ "options": "JSON", "read_only": 1 }, - { - "fieldname": "import_log", - "fieldtype": "Code", - "label": "Import Log", - "options": "JSON" - }, { "fieldname": "import_log_section", "fieldtype": "Section Break", @@ -100,7 +93,7 @@ "fieldtype": "Select", "hidden": 1, "label": "Status", - "options": "Pending\nSuccess\nPartial Success\nError", + "options": "Pending\nSuccess\nPartial Success\nError\nPartially Completed", "read_only": 1 }, { @@ -169,7 +162,7 @@ ], "hide_toolbar": 1, "links": [], - "modified": "2021-04-11 01:50:42.074623", + "modified": "2021-12-26 23:51:00.280093", "modified_by": "Administrator", "module": "Core", "name": "Data Import", diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index fb6c5b147a..166a39b39e 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -29,10 +29,6 @@ class DataImport(Document): self.validate_google_sheets_url() def validate_import_file(self): - - if self.status == 'Pending' and self.import_log: - frappe.throw(_("File cannot be changed for partially completed imports. Either continue import or make a fresh import")) - if self.import_file: # validate template self.get_importer() @@ -93,10 +89,20 @@ class DataImport(Document): @frappe.whitelist() def get_preview_from_template(data_import, import_file=None, google_sheets_url=None): - return frappe.get_doc("Data Import", data_import).get_preview_from_template( + preview_data = frappe.get_doc("Data Import", data_import).get_preview_from_template( import_file, google_sheets_url ) + # get first 10 import log if any + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes"], + filters={"data_import": data_import}, + order_by="log_index", limit=10) + + return { + 'preview_data': preview_data, + 'import_log': import_log + } + @frappe.whitelist() def form_start_import(data_import): @@ -157,6 +163,23 @@ def download_import_log(data_import_name): data_import = frappe.get_doc("Data Import", data_import_name) data_import.download_import_log() +@frappe.whitelist() +def get_import_status(data_import_name): + import_status = {} + + logs = frappe.get_all('Data Import Log', fields=['count(*) as count', 'success'], + filters={'data_import': data_import_name}, + group_by='success') + + for log in logs: + if log.get('success'): + import_status['success'] = log.get('count') + else: + import_status['failed'] = log.get('count') + + import_status['total_records'] = len(logs) + + return import_status def import_file( doctype, file_path, import_type, submit_after_import=False, console=False diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 706c40c910..06f6bcd3f3 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -58,7 +58,6 @@ class Importer: frappe.flags.in_import = True frappe.flags.mute_emails = self.data_import.mute_emails - self.data_import.db_set("status", "Pending") self.data_import.db_set("template_warnings", "") def import_data(self): @@ -79,13 +78,14 @@ class Importer: return # setup import log - if self.data_import.import_log: - import_log = frappe.parse_json(self.data_import.import_log) - else: - import_log = [] + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "log_index"], + filters={"data_import": self.data_import.name}, + order_by="log_index") + + log_index = 0 # Do not remove rows in case of retry after an error or pending data import - if self.data_import.status == 'Partial Success': + if self.data_import.status == "Partial Success": # remove previous failures from import log only in case of retry after partial success import_log = [log for log in import_log if log.get("success")] @@ -93,8 +93,10 @@ class Importer: imported_rows = [] for log in import_log: log = frappe._dict(log) - if log.success or self.data_import.status == 'Pending': - imported_rows += log.row_indexes + if log.success or self.data_import.status == "Partially Completed": + imported_rows += json.loads(log.row_indexes) + + log_index = log.log_index # start import total_payload_count = len(payloads) @@ -148,33 +150,39 @@ class Importer: }, ) - import_log.append( - frappe._dict(success=True, docname=doc.name, row_indexes=row_indexes) - ) + import_log.append(create_import_log(self.data_import.name, log_index, { + 'success': True, + 'docname': doc.name, + 'row_indexes': row_indexes + })) - # Update import log after every successful import - # This is done for cases where the background job might get terminated due to timeout - # In such cases the job can be rerun only for pending rows by referring to import logs - self.data_import.db_set("import_log", json.dumps(import_log)) + log_index += 1 + + if not self.data_import.status == "Partially Completed": + self.data_import.db_set("status", "Partially Completed") # commit after every successful import frappe.db.commit() except Exception: - import_log.append( - frappe._dict( - success=False, - exception=frappe.get_traceback(), - messages=frappe.local.message_log, - row_indexes=row_indexes, - ) - ) frappe.clear_messages() # rollback if exception frappe.db.rollback() + import_log.append(create_import_log(self.data_import.name, log_index, { + 'success': False, + 'exception': frappe.get_traceback(), + 'messages': frappe.local.message_log, + 'row_indexes': row_indexes + })) + + # commit after creating log for failure + frappe.db.commit() + log_index += 1 + # set status failures = [log for log in import_log if not log.get("success")] + print(failures, "$#$#$#") if len(failures) == total_payload_count: status = "Pending" elif len(failures) > 0: @@ -186,7 +194,6 @@ class Importer: self.print_import_log(import_log) else: self.data_import.db_set("status", status) - self.data_import.db_set("import_log", json.dumps(import_log)) self.after_import() @@ -277,13 +284,17 @@ class Importer: if not self.data_import: return - import_log = frappe.parse_json(self.data_import.import_log or "[]") + + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "messages", "exception", "docname"], + filters={"data_import": self.data_import.name}, + order_by="log_index") + header_row = ["Row Numbers", "Status", "Message", "Exception"] rows = [header_row] for log in import_log: - row_number = log.get("row_indexes")[0] + row_number = json.loads(log.get("row_indexes"))[0] status = "Success" if log.get('success') else "Failure" message = "Successfully Imported {0}".format(log.get('docname')) if log.get('success') else \ log.get("messages") @@ -1200,3 +1211,17 @@ def df_as_json(df): def get_select_options(df): return [d for d in (df.options or "").split("\n") if d] + +def create_import_log(data_import, log_index, log_details): + return frappe.get_doc({ + 'doctype': 'Data Import Log', + 'log_index': log_index, + 'success': log_details.get('success'), + 'data_import': data_import, + 'row_indexes': json.dumps(log_details.get('row_indexes')), + 'docname': log_details.get('docname'), + 'message': json.dumps(log_details.get('messages')) if log_details.get('messages') else None, + 'exception': log_details.get('exception') + }).insert(ignore_permissions=True) + + diff --git a/frappe/public/js/frappe/data_import/import_preview.js b/frappe/public/js/frappe/data_import/import_preview.js index 2264042539..b153718c70 100644 --- a/frappe/public/js/frappe/data_import/import_preview.js +++ b/frappe/public/js/frappe/data_import/import_preview.js @@ -331,7 +331,7 @@ frappe.data_import.ImportPreview = class ImportPreview { is_row_imported(row) { let serial_no = row[0].content; return this.import_log.find(log => { - return log.success && log.row_indexes.includes(serial_no); + return log.success && JSON.parse(log.row_indexes || '[]').includes(serial_no); }); } }; From 48f8af5bf8d3c5bf210dcf5d08d389a794762845 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 27 Dec 2021 12:26:05 +0530 Subject: [PATCH 084/151] fix: import log initialization --- frappe/core/doctype/data_import/importer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 06f6bcd3f3..6d88d25286 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -80,7 +80,7 @@ class Importer: # setup import log import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "log_index"], filters={"data_import": self.data_import.name}, - order_by="log_index") + order_by="log_index") or [] log_index = 0 @@ -182,7 +182,6 @@ class Importer: # set status failures = [log for log in import_log if not log.get("success")] - print(failures, "$#$#$#") if len(failures) == total_payload_count: status = "Pending" elif len(failures) > 0: From e925d26c89583ea39a56d6584aff37ee6921795a Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 27 Dec 2021 20:31:33 +0530 Subject: [PATCH 085/151] fix: Error message display and linting issues --- .../core/doctype/data_import/data_import.js | 21 ++++++++++++------- frappe/core/doctype/data_import/importer.py | 13 ++++++++---- .../core/doctype/data_import/test_importer.py | 21 ++++++++++++------- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index e395a199b8..bf3bbac4aa 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -146,12 +146,12 @@ frappe.ui.form.on('Data Import', { let failed_records = cint(r.message.failed); let total_records = cint(r.message.total_records); - if (!total_records) return + if (!total_records) return; let message; if (failed_records === 0) { let message_args = [successful_records]; - if (me.frm.doc.import_type === 'Insert New Records') { + if (frm.doc.import_type === 'Insert New Records') { message = successful_records > 1 ? __('Successfully imported {0} records.', message_args) @@ -428,7 +428,12 @@ frappe.ui.form.on('Data Import', { 'page_limit_length': 5000 }, callback: function(r) { - let logs = r.message + let logs = r.message; + + if (logs.length === 0) return; + + frm.toggle_display('import_log_section', true); + let rows = logs .map(log => { let html = ''; @@ -451,7 +456,7 @@ frappe.ui.form.on('Data Import', { ]); } } else { - let messages = (log.messages || []) + let messages = (JSON.parse(log.messages || '[]')) .map(JSON.parse) .map(m => { let title = m.title ? `${m.title}` : ''; @@ -505,11 +510,13 @@ frappe.ui.form.on('Data Import', { ${rows} `); - } - }); + } + }); }, show_import_log(frm) { + frm.toggle_display('import_log_section', false); + if (frm.import_in_progress) { return; } @@ -524,7 +531,7 @@ frappe.ui.form.on('Data Import', { }, 'callback': function(r) { let count = r.message; - if(count < 5000) { + if (count < 5000) { frm.trigger('render_import_log'); } else { frm.toggle_display('import_log_section', false); diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 6d88d25286..6915a4e5d1 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -165,14 +165,16 @@ class Importer: frappe.db.commit() except Exception: + messages = frappe.local.message_log frappe.clear_messages() + # rollback if exception frappe.db.rollback() import_log.append(create_import_log(self.data_import.name, log_index, { 'success': False, 'exception': frappe.get_traceback(), - 'messages': frappe.local.message_log, + 'messages': messages, 'row_indexes': row_indexes })) @@ -262,11 +264,14 @@ class Importer: if not self.data_import: return - import_log = frappe.parse_json(self.data_import.import_log or "[]") + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success"], + filters={"data_import": self.data_import.name}, + order_by="log_index") or [] + failures = [log for log in import_log if not log.get("success")] row_indexes = [] for f in failures: - row_indexes.extend(f.get("row_indexes", [])) + row_indexes.extend(json.loads(f.get("row_indexes", []))) # de duplicate row_indexes = list(set(row_indexes)) @@ -1219,7 +1224,7 @@ def create_import_log(data_import, log_index, log_details): 'data_import': data_import, 'row_indexes': json.dumps(log_details.get('row_indexes')), 'docname': log_details.get('docname'), - 'message': json.dumps(log_details.get('messages')) if log_details.get('messages') else None, + 'messages': json.dumps(log_details.get('messages', '[]')), 'exception': log_details.get('exception') }).insert(ignore_permissions=True) diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py index e1bc0e7ca5..d09b4f69e7 100644 --- a/frappe/core/doctype/data_import/test_importer.py +++ b/frappe/core/doctype/data_import/test_importer.py @@ -3,6 +3,7 @@ # License: MIT. See LICENSE import unittest import frappe +import json from frappe.core.doctype.data_import.importer import Importer from frappe.utils import getdate, format_duration @@ -60,15 +61,19 @@ class TestImporter(unittest.TestCase): frappe.local.message_log = [] data_import.start_import() data_import.reload() - import_log = frappe.parse_json(data_import.import_log) - self.assertEqual(import_log[0]['row_indexes'], [2,3]) - expected_error = "Error: Child 1 of DocType for Import Row #1: Value missing for: Child Title" - self.assertEqual(frappe.parse_json(import_log[0]['messages'][0])['message'], expected_error) - expected_error = "Error: Child 1 of DocType for Import Row #2: Value missing for: Child Title" - self.assertEqual(frappe.parse_json(import_log[0]['messages'][1])['message'], expected_error) - self.assertEqual(import_log[1]['row_indexes'], [4]) - self.assertEqual(frappe.parse_json(import_log[1]['messages'][0])['message'], "Title is required") + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "messages", "exception", "docname"], + filters={"data_import": data_import.name}, + order_by="log_index") + + self.assertEqual(frappe.parse_json(import_log[0]['row_indexes']), [2,3]) + expected_error = "Error: Child 1 of DocType for Import Row #1: Value missing for: Child Title" + self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[0]['messages'])[0])['message'], expected_error) + expected_error = "Error: Child 1 of DocType for Import Row #2: Value missing for: Child Title" + self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[0]['messages'])[1])['message'], expected_error) + + self.assertEqual(frappe.parse_json(import_log[1]['row_indexes']), [4]) + self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[1]['messages'])[0])['message'], "Title is required") def test_data_import_update(self): existing_doc = frappe.get_doc( From 4a0343fdf18ba0a1a6447244def3c9566d5759b9 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 3 Jan 2022 13:02:14 +0530 Subject: [PATCH 086/151] fix: Total status count --- frappe/core/doctype/data_import/data_import.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index 166a39b39e..4aacb6925b 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -12,6 +12,7 @@ from frappe.model.document import Document from frappe.modules.import_file import import_file_by_path from frappe.utils.background_jobs import enqueue from frappe.utils.csvutils import validate_google_sheets_url +from frappe.utils import cint class DataImport(Document): @@ -177,7 +178,8 @@ def get_import_status(data_import_name): else: import_status['failed'] = log.get('count') - import_status['total_records'] = len(logs) + import_status['total_records'] = cint(import_status.get('success')) + \ + cint(import_status.get('failed')) return import_status From f9126bccc998926a1e5e5f7e6edd653054281176 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 25 Jan 2022 11:44:07 +0530 Subject: [PATCH 087/151] fix: Show import logs in data import doctype --- frappe/core/doctype/data_import/data_import.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index bf3bbac4aa..4a1ac7c255 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -425,7 +425,8 @@ frappe.ui.form.on('Data Import', { 'data_import': frm.doc.name }, 'fields': ['success', 'docname', 'messages', 'exception', 'row_indexes'], - 'page_limit_length': 5000 + 'limit_page_length': 5000, + 'order_by': 'log_index' }, callback: function(r) { let logs = r.message; From cafd513c494c69d94266dcb3bdbec0a70e962514 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 31 Jan 2022 11:03:08 +0530 Subject: [PATCH 088/151] fix: Remove partially completed status --- .../core/doctype/data_import/data_import.js | 9 +- .../core/doctype/data_import/data_import.json | 84 ++++++++++++++----- .../doctype/data_import/data_import_list.js | 1 - frappe/core/doctype/data_import/importer.py | 8 +- 4 files changed, 67 insertions(+), 35 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index 4a1ac7c255..6ea95fba02 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -112,14 +112,7 @@ frappe.ui.form.on('Data Import', { if (!frm.is_new() && (frm.has_import_file())) { let label = frm.doc.status === 'Pending' ? __('Start Import') : __('Retry'); - - if (frm.doc.status == 'Partially Completed') { - label = __('Continue Import'); - } - - if (!frm.import_in_progress) { - frm.page.set_primary_action(label, () => frm.events.start_import(frm)); - } + frm.page.set_primary_action(label, () => frm.events.start_import(frm)); } else { frm.page.set_primary_action(__('Save'), () => frm.save()); } diff --git a/frappe/core/doctype/data_import/data_import.json b/frappe/core/doctype/data_import/data_import.json index 997c14cf7f..e8b8b7a546 100644 --- a/frappe/core/doctype/data_import/data_import.json +++ b/frappe/core/doctype/data_import/data_import.json @@ -36,7 +36,9 @@ "label": "Document Type", "options": "DocType", "reqd": 1, - "set_only_once": 1 + "set_only_once": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "import_type", @@ -45,7 +47,9 @@ "label": "Import Type", "options": "\nInsert New Records\nUpdate Existing Records", "reqd": 1, - "set_only_once": 1 + "set_only_once": 1, + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:!doc.__islocal", @@ -53,21 +57,29 @@ "fieldtype": "Attach", "in_list_view": 1, "label": "Import File", - "read_only_depends_on": "eval: ['Success', 'Partial Success', 'Partially Completed'].includes(doc.status)" + "read_only_depends_on": "eval: ['Success', 'Partial Success', 'Partially Completed'].includes(doc.status)", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "import_preview", "fieldtype": "HTML", - "label": "Import Preview" + "label": "Import Preview", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "section_import_preview", "fieldtype": "Section Break", - "label": "Preview" + "label": "Preview", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_5", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "template_options", @@ -75,17 +87,23 @@ "hidden": 1, "label": "Template Options", "options": "JSON", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "import_log_section", "fieldtype": "Section Break", - "label": "Import Log" + "label": "Import Log", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "import_log_preview", "fieldtype": "HTML", - "label": "Import Log Preview" + "label": "Import Log Preview", + "show_days": 1, + "show_seconds": 1 }, { "default": "Pending", @@ -93,57 +111,75 @@ "fieldtype": "Select", "hidden": 1, "label": "Status", - "options": "Pending\nSuccess\nPartial Success\nError\nPartially Completed", - "read_only": 1 + "options": "Pending\nSuccess\nPartial Success\nError", + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "template_warnings", "fieldtype": "Code", "hidden": 1, "label": "Template Warnings", - "options": "JSON" + "options": "JSON", + "show_days": 1, + "show_seconds": 1 }, { "default": "0", "fieldname": "submit_after_import", "fieldtype": "Check", "label": "Submit After Import", - "set_only_once": 1 + "set_only_once": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "import_warnings_section", "fieldtype": "Section Break", - "label": "Import File Errors and Warnings" + "label": "Import File Errors and Warnings", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "import_warnings", "fieldtype": "HTML", - "label": "Import Warnings" + "label": "Import Warnings", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:!doc.__islocal", "fieldname": "download_template", "fieldtype": "Button", - "label": "Download Template" + "label": "Download Template", + "show_days": 1, + "show_seconds": 1 }, { "default": "1", "fieldname": "mute_emails", "fieldtype": "Check", "label": "Don't Send Emails", - "set_only_once": 1 + "set_only_once": 1, + "show_days": 1, + "show_seconds": 1 }, { "default": "0", "fieldname": "show_failed_logs", "fieldtype": "Check", - "label": "Show Failed Logs" + "label": "Show Failed Logs", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:!doc.__islocal && !doc.import_file", "fieldname": "html_5", "fieldtype": "HTML", - "options": "
Or
" + "options": "
Or
", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:!doc.__islocal && !doc.import_file\n", @@ -151,18 +187,22 @@ "fieldname": "google_sheets_url", "fieldtype": "Data", "label": "Import from Google Sheets", - "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)" + "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)", + "show_days": 1, + "show_seconds": 1 }, { "depends_on": "eval:doc.google_sheets_url && !doc.__unsaved && ['Success', 'Partial Success'].includes(doc.status)", "fieldname": "refresh_google_sheet", "fieldtype": "Button", - "label": "Refresh Google Sheet" + "label": "Refresh Google Sheet", + "show_days": 1, + "show_seconds": 1 } ], "hide_toolbar": 1, "links": [], - "modified": "2021-12-26 23:51:00.280093", + "modified": "2022-01-31 10:50:13.015426", "modified_by": "Administrator", "module": "Core", "name": "Data Import", diff --git a/frappe/core/doctype/data_import/data_import_list.js b/frappe/core/doctype/data_import/data_import_list.js index 84b0f0ada1..6ab750ba25 100644 --- a/frappe/core/doctype/data_import/data_import_list.js +++ b/frappe/core/doctype/data_import/data_import_list.js @@ -19,7 +19,6 @@ frappe.listview_settings['Data Import'] = { 'Pending': 'orange', 'Not Started': 'orange', 'Partial Success': 'orange', - 'Partially Completed': 'orange', 'Success': 'green', 'In Progress': 'orange', 'Error': 'red' diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 6915a4e5d1..8c98099c1a 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -85,7 +85,7 @@ class Importer: log_index = 0 # Do not remove rows in case of retry after an error or pending data import - if self.data_import.status == "Partial Success": + if self.data_import.status == "Partial Success" and len(import_log) > len(payloads): # remove previous failures from import log only in case of retry after partial success import_log = [log for log in import_log if log.get("success")] @@ -93,7 +93,7 @@ class Importer: imported_rows = [] for log in import_log: log = frappe._dict(log) - if log.success or self.data_import.status == "Partially Completed": + if log.success or len(import_log) < len(payloads): imported_rows += json.loads(log.row_indexes) log_index = log.log_index @@ -158,8 +158,8 @@ class Importer: log_index += 1 - if not self.data_import.status == "Partially Completed": - self.data_import.db_set("status", "Partially Completed") + if not self.data_import.status == "Partial Success": + self.data_import.db_set("status", "Partial Success") # commit after every successful import frappe.db.commit() From eb574ca9f1f39c54d96105a94e0e8a3a4a12964b Mon Sep 17 00:00:00 2001 From: kamaljohnson Date: Tue, 1 Feb 2022 19:01:41 +0530 Subject: [PATCH 089/151] feat: add filter for append_to wrt imap_folder --- frappe/email/doctype/email_account/email_account.py | 10 +++++----- frappe/email/receive.py | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index ee230d33f0..772173fcd2 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -481,11 +481,11 @@ class EmailAccount(Document): if self.use_imap: # process all given imap folder for folder in self.imap_folder: - email_server.select_imap_folder(folder.folder_name) - email_server.settings['uid_validity'] = folder.uidvalidity - messages = email_server.get_messages(folder=folder.folder_name) or {} - append_to = folder.append_to - process_mail(messages, append_to) + if email_server.select_imap_folder(folder.folder_name): + frappe.log_error(f'FOLDER NAME: {folder.folder_name} PRESENT') + email_server.settings['uid_validity'] = folder.uidvalidity + messages = email_server.get_messages(folder=f'"{folder.folder_name}"') or {} + process_mail(messages, folder.append_to) else: # process the pop3 account messages = email_server.get_messages() or {} diff --git a/frappe/email/receive.py b/frappe/email/receive.py index cc4ec23e4e..30dd146fa7 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -108,7 +108,8 @@ class EmailServer: raise def select_imap_folder(self, folder): - self.imap.select(folder) + res = self.imap.select(f'"{folder}"') + return res[0] == 'OK' # The folder exsits TODO: handle other resoponses too def logout(self): if cint(self.settings.use_imap): From 43ac81be5ddd00a4352a089d0571a1075595b521 Mon Sep 17 00:00:00 2001 From: kamaljohnson Date: Tue, 1 Feb 2022 19:04:18 +0530 Subject: [PATCH 090/151] refactor: remove debug logs --- frappe/email/doctype/email_account/email_account.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 772173fcd2..1c53ae9faf 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -482,7 +482,6 @@ class EmailAccount(Document): # process all given imap folder for folder in self.imap_folder: if email_server.select_imap_folder(folder.folder_name): - frappe.log_error(f'FOLDER NAME: {folder.folder_name} PRESENT') email_server.settings['uid_validity'] = folder.uidvalidity messages = email_server.get_messages(folder=f'"{folder.folder_name}"') or {} process_mail(messages, folder.append_to) From 300031aa36941ea17292852f1ec2bc1a45fc67e8 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 1 Feb 2022 19:38:33 +0530 Subject: [PATCH 091/151] revert "refactor: get_docinfo without side effects" This reverts commit 79f5a6b640c9672e2d123fd410daa1a434887a18. --- frappe/desk/form/load.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 3bc65cfd01..58d5b30103 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -42,8 +42,7 @@ def getdoc(doctype, name, user=None): # add file list doc.add_viewed() - frappe.response["docinfo"] = get_docinfo(doc) - + get_docinfo(doc) except Exception: frappe.errprint(frappe.utils.get_traceback()) @@ -119,6 +118,7 @@ def get_docinfo(doc=None, doctype=None, name=None): update_user_info(docinfo) + frappe.response["docinfo"] = docinfo return docinfo def add_comments(doc, docinfo): From 58db3765098ad6ea9f6572ebc0fc286eb9b05251 Mon Sep 17 00:00:00 2001 From: kamaljohnson Date: Tue, 1 Feb 2022 20:06:26 +0530 Subject: [PATCH 092/151] fix: email syncing with "seen" emails even if email_sync filter set to "unseen" issue --- frappe/email/doctype/email_account/email_account.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 1c53ae9faf..c3ab11d2e9 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -467,7 +467,9 @@ class EmailAccount(Document): for index, message in enumerate(messages.get("latest_messages", [])): uid = messages['uid_list'][index] if messages.get('uid_list') else None seen_status = 1 if messages.get('seen_status', {}).get(uid) == 'SEEN' else 0 - mails.append(InboundMail(message, self, uid, seen_status, append_to)) + if not (self.email_sync_option == 'UNSEEN' and seen_status): + # only append the emails with status != 'SEEN' if sync option is set to 'UNSEEN' + mails.append(InboundMail(message, self, uid, seen_status, append_to)) if frappe.local.flags.in_test: return [InboundMail(msg, self) for msg in test_mails or []] From 2ca5c390d062f42fca95dc61cce154090b749c8b Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Tue, 1 Feb 2022 23:00:41 +0530 Subject: [PATCH 093/151] fix: Use db insert for logs --- .../core/doctype/data_import/data_import.js | 9 +- .../core/doctype/data_import/data_import.json | 420 ++++++++---------- .../core/doctype/data_import/data_import.py | 25 +- frappe/core/doctype/data_import/importer.py | 21 +- .../data_import_log/data_import_log.json | 4 +- 5 files changed, 229 insertions(+), 250 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.js b/frappe/core/doctype/data_import/data_import.js index 6ea95fba02..1460f8698e 100644 --- a/frappe/core/doctype/data_import/data_import.js +++ b/frappe/core/doctype/data_import/data_import.js @@ -278,14 +278,15 @@ frappe.ui.form.on('Data Import', { } }) .then(r => { - let preview_data = r.message.preview_data; - let import_log = r.message.import_log; - frm.events.show_import_preview(frm, preview_data, import_log); + let preview_data = r.message; + frm.events.show_import_preview(frm, preview_data); frm.events.show_import_warnings(frm, preview_data); }); }, - show_import_preview(frm, preview_data, import_log) { + show_import_preview(frm, preview_data) { + let import_log = preview_data.import_log; + if ( frm.import_preview && frm.import_preview.doctype === frm.doc.reference_doctype diff --git a/frappe/core/doctype/data_import/data_import.json b/frappe/core/doctype/data_import/data_import.json index e8b8b7a546..9e948dac8c 100644 --- a/frappe/core/doctype/data_import/data_import.json +++ b/frappe/core/doctype/data_import/data_import.json @@ -1,227 +1,197 @@ { - "actions": [], - "autoname": "format:{reference_doctype} Import on {creation}", - "beta": 1, - "creation": "2019-08-04 14:16:08.318714", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "reference_doctype", - "import_type", - "download_template", - "import_file", - "html_5", - "google_sheets_url", - "refresh_google_sheet", - "column_break_5", - "status", - "submit_after_import", - "mute_emails", - "template_options", - "import_warnings_section", - "template_warnings", - "import_warnings", - "section_import_preview", - "import_preview", - "import_log_section", - "show_failed_logs", - "import_log_preview" - ], - "fields": [ - { - "fieldname": "reference_doctype", - "fieldtype": "Link", - "in_list_view": 1, - "label": "Document Type", - "options": "DocType", - "reqd": 1, - "set_only_once": 1, - "show_days": 1, - "show_seconds": 1 - }, - { - "fieldname": "import_type", - "fieldtype": "Select", - "in_list_view": 1, - "label": "Import Type", - "options": "\nInsert New Records\nUpdate Existing Records", - "reqd": 1, - "set_only_once": 1, - "show_days": 1, - "show_seconds": 1 - }, - { - "depends_on": "eval:!doc.__islocal", - "fieldname": "import_file", - "fieldtype": "Attach", - "in_list_view": 1, - "label": "Import File", - "read_only_depends_on": "eval: ['Success', 'Partial Success', 'Partially Completed'].includes(doc.status)", - "show_days": 1, - "show_seconds": 1 - }, - { - "fieldname": "import_preview", - "fieldtype": "HTML", - "label": "Import Preview", - "show_days": 1, - "show_seconds": 1 - }, - { - "fieldname": "section_import_preview", - "fieldtype": "Section Break", - "label": "Preview", - "show_days": 1, - "show_seconds": 1 - }, - { - "fieldname": "column_break_5", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 - }, - { - "fieldname": "template_options", - "fieldtype": "Code", - "hidden": 1, - "label": "Template Options", - "options": "JSON", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 - }, - { - "fieldname": "import_log_section", - "fieldtype": "Section Break", - "label": "Import Log", - "show_days": 1, - "show_seconds": 1 - }, - { - "fieldname": "import_log_preview", - "fieldtype": "HTML", - "label": "Import Log Preview", - "show_days": 1, - "show_seconds": 1 - }, - { - "default": "Pending", - "fieldname": "status", - "fieldtype": "Select", - "hidden": 1, - "label": "Status", - "options": "Pending\nSuccess\nPartial Success\nError", - "read_only": 1, - "show_days": 1, - "show_seconds": 1 - }, - { - "fieldname": "template_warnings", - "fieldtype": "Code", - "hidden": 1, - "label": "Template Warnings", - "options": "JSON", - "show_days": 1, - "show_seconds": 1 - }, - { - "default": "0", - "fieldname": "submit_after_import", - "fieldtype": "Check", - "label": "Submit After Import", - "set_only_once": 1, - "show_days": 1, - "show_seconds": 1 - }, - { - "fieldname": "import_warnings_section", - "fieldtype": "Section Break", - "label": "Import File Errors and Warnings", - "show_days": 1, - "show_seconds": 1 - }, - { - "fieldname": "import_warnings", - "fieldtype": "HTML", - "label": "Import Warnings", - "show_days": 1, - "show_seconds": 1 - }, - { - "depends_on": "eval:!doc.__islocal", - "fieldname": "download_template", - "fieldtype": "Button", - "label": "Download Template", - "show_days": 1, - "show_seconds": 1 - }, - { - "default": "1", - "fieldname": "mute_emails", - "fieldtype": "Check", - "label": "Don't Send Emails", - "set_only_once": 1, - "show_days": 1, - "show_seconds": 1 - }, - { - "default": "0", - "fieldname": "show_failed_logs", - "fieldtype": "Check", - "label": "Show Failed Logs", - "show_days": 1, - "show_seconds": 1 - }, - { - "depends_on": "eval:!doc.__islocal && !doc.import_file", - "fieldname": "html_5", - "fieldtype": "HTML", - "options": "
Or
", - "show_days": 1, - "show_seconds": 1 - }, - { - "depends_on": "eval:!doc.__islocal && !doc.import_file\n", - "description": "Must be a publicly accessible Google Sheets URL", - "fieldname": "google_sheets_url", - "fieldtype": "Data", - "label": "Import from Google Sheets", - "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)", - "show_days": 1, - "show_seconds": 1 - }, - { - "depends_on": "eval:doc.google_sheets_url && !doc.__unsaved && ['Success', 'Partial Success'].includes(doc.status)", - "fieldname": "refresh_google_sheet", - "fieldtype": "Button", - "label": "Refresh Google Sheet", - "show_days": 1, - "show_seconds": 1 - } - ], - "hide_toolbar": 1, - "links": [], - "modified": "2022-01-31 10:50:13.015426", - "modified_by": "Administrator", - "module": "Core", - "name": "Data Import", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 + "actions": [], + "autoname": "format:{reference_doctype} Import on {creation}", + "beta": 1, + "creation": "2019-08-04 14:16:08.318714", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "reference_doctype", + "import_type", + "download_template", + "import_file", + "payload_count", + "html_5", + "google_sheets_url", + "refresh_google_sheet", + "column_break_5", + "status", + "submit_after_import", + "mute_emails", + "template_options", + "import_warnings_section", + "template_warnings", + "import_warnings", + "section_import_preview", + "import_preview", + "import_log_section", + "show_failed_logs", + "import_log_preview" + ], + "fields": [ + { + "fieldname": "reference_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Document Type", + "options": "DocType", + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "import_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Import Type", + "options": "\nInsert New Records\nUpdate Existing Records", + "reqd": 1, + "set_only_once": 1 + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "import_file", + "fieldtype": "Attach", + "in_list_view": 1, + "label": "Import File", + "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)" + }, + { + "fieldname": "import_preview", + "fieldtype": "HTML", + "label": "Import Preview" + }, + { + "fieldname": "section_import_preview", + "fieldtype": "Section Break", + "label": "Preview" + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "fieldname": "template_options", + "fieldtype": "Code", + "hidden": 1, + "label": "Template Options", + "options": "JSON", + "read_only": 1 + }, + { + "fieldname": "import_log_section", + "fieldtype": "Section Break", + "label": "Import Log" + }, + { + "fieldname": "import_log_preview", + "fieldtype": "HTML", + "label": "Import Log Preview" + }, + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "label": "Status", + "options": "Pending\nSuccess\nPartial Success\nError", + "read_only": 1 + }, + { + "fieldname": "template_warnings", + "fieldtype": "Code", + "hidden": 1, + "label": "Template Warnings", + "options": "JSON" + }, + { + "default": "0", + "fieldname": "submit_after_import", + "fieldtype": "Check", + "label": "Submit After Import", + "set_only_once": 1 + }, + { + "fieldname": "import_warnings_section", + "fieldtype": "Section Break", + "label": "Import File Errors and Warnings" + }, + { + "fieldname": "import_warnings", + "fieldtype": "HTML", + "label": "Import Warnings" + }, + { + "depends_on": "eval:!doc.__islocal", + "fieldname": "download_template", + "fieldtype": "Button", + "label": "Download Template" + }, + { + "default": "1", + "fieldname": "mute_emails", + "fieldtype": "Check", + "label": "Don't Send Emails", + "set_only_once": 1 + }, + { + "default": "0", + "fieldname": "show_failed_logs", + "fieldtype": "Check", + "label": "Show Failed Logs" + }, + { + "depends_on": "eval:!doc.__islocal && !doc.import_file", + "fieldname": "html_5", + "fieldtype": "HTML", + "options": "
Or
" + }, + { + "depends_on": "eval:!doc.__islocal && !doc.import_file\n", + "description": "Must be a publicly accessible Google Sheets URL", + "fieldname": "google_sheets_url", + "fieldtype": "Data", + "label": "Import from Google Sheets", + "read_only_depends_on": "eval: ['Success', 'Partial Success'].includes(doc.status)" + }, + { + "depends_on": "eval:doc.google_sheets_url && !doc.__unsaved && ['Success', 'Partial Success'].includes(doc.status)", + "fieldname": "refresh_google_sheet", + "fieldtype": "Button", + "label": "Refresh Google Sheet" + }, + { + "fieldname": "payload_count", + "fieldtype": "Int", + "hidden": 1, + "label": "Payload Count", + "read_only": 1 + } + ], + "hide_toolbar": 1, + "links": [], + "modified": "2022-02-01 20:08:37.624914", + "modified_by": "Administrator", + "module": "Core", + "name": "Data Import", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index 4aacb6925b..0c8ba072a1 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -28,6 +28,7 @@ class DataImport(Document): self.validate_import_file() self.validate_google_sheets_url() + self.set_payload_count() def validate_import_file(self): if self.import_file: @@ -39,6 +40,12 @@ class DataImport(Document): return validate_google_sheets_url(self.google_sheets_url) + def set_payload_count(self): + if self.import_file: + i = self.get_importer() + payloads = i.import_file.get_payloads_for_import() + self.payload_count = len(payloads) + @frappe.whitelist() def get_preview_from_template(self, import_file=None, google_sheets_url=None): if import_file: @@ -90,21 +97,10 @@ class DataImport(Document): @frappe.whitelist() def get_preview_from_template(data_import, import_file=None, google_sheets_url=None): - preview_data = frappe.get_doc("Data Import", data_import).get_preview_from_template( + return frappe.get_doc("Data Import", data_import).get_preview_from_template( import_file, google_sheets_url ) - # get first 10 import log if any - import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes"], - filters={"data_import": data_import}, - order_by="log_index", limit=10) - - return { - 'preview_data': preview_data, - 'import_log': import_log - } - - @frappe.whitelist() def form_start_import(data_import): return frappe.get_doc("Data Import", data_import).start_import() @@ -172,14 +168,15 @@ def get_import_status(data_import_name): filters={'data_import': data_import_name}, group_by='success') + total_payload_count = frappe.db.get_value('Data Import', data_import_name, 'payload_count') + for log in logs: if log.get('success'): import_status['success'] = log.get('count') else: import_status['failed'] = log.get('count') - import_status['total_records'] = cint(import_status.get('success')) + \ - cint(import_status.get('failed')) + import_status['total_records'] = total_payload_count return import_status diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py index 8c98099c1a..d13f4c1389 100644 --- a/frappe/core/doctype/data_import/importer.py +++ b/frappe/core/doctype/data_import/importer.py @@ -47,7 +47,13 @@ class Importer: ) def get_data_for_import_preview(self): - return self.import_file.get_data_for_import_preview() + out = self.import_file.get_data_for_import_preview() + + out.import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success"], + filters={"data_import": self.data_import.name}, + order_by="log_index", limit=10) + + return out def before_import(self): # set user lang for translations @@ -85,7 +91,7 @@ class Importer: log_index = 0 # Do not remove rows in case of retry after an error or pending data import - if self.data_import.status == "Partial Success" and len(import_log) > len(payloads): + if self.data_import.status == "Partial Success" and len(import_log) >= self.data_import.payload_count: # remove previous failures from import log only in case of retry after partial success import_log = [log for log in import_log if log.get("success")] @@ -93,7 +99,7 @@ class Importer: imported_rows = [] for log in import_log: log = frappe._dict(log) - if log.success or len(import_log) < len(payloads): + if log.success or len(import_log) < self.data_import.payload_count: imported_rows += json.loads(log.row_indexes) log_index = log.log_index @@ -182,6 +188,11 @@ class Importer: frappe.db.commit() log_index += 1 + # Logs are db inserted directly so will have to be fetched again + import_log = frappe.db.get_all("Data Import Log", fields=["row_indexes", "success", "log_index"], + filters={"data_import": self.data_import.name}, + order_by="log_index") or [] + # set status failures = [log for log in import_log if not log.get("success")] if len(failures) == total_payload_count: @@ -1217,7 +1228,7 @@ def get_select_options(df): return [d for d in (df.options or "").split("\n") if d] def create_import_log(data_import, log_index, log_details): - return frappe.get_doc({ + frappe.get_doc({ 'doctype': 'Data Import Log', 'log_index': log_index, 'success': log_details.get('success'), @@ -1226,6 +1237,6 @@ def create_import_log(data_import, log_index, log_details): 'docname': log_details.get('docname'), 'messages': json.dumps(log_details.get('messages', '[]')), 'exception': log_details.get('exception') - }).insert(ignore_permissions=True) + }).db_insert() diff --git a/frappe/core/doctype/data_import_log/data_import_log.json b/frappe/core/doctype/data_import_log/data_import_log.json index a39eb9be1d..8747728945 100644 --- a/frappe/core/doctype/data_import_log/data_import_log.json +++ b/frappe/core/doctype/data_import_log/data_import_log.json @@ -3,7 +3,7 @@ "creation": "2021-12-25 16:12:20.205889", "doctype": "DocType", "editable_grid": 1, - "engine": "InnoDB", + "engine": "MyISAM", "field_order": [ "data_import", "row_indexes", @@ -60,7 +60,7 @@ "in_create": 1, "index_web_pages_for_search": 1, "links": [], - "modified": "2021-12-27 11:19:19.646076", + "modified": "2021-12-28 11:19:19.646076", "modified_by": "Administrator", "module": "Core", "name": "Data Import Log", From 18820bf44e56cbfee380bcc2b037f46ea87ec487 Mon Sep 17 00:00:00 2001 From: kamaljohnson Date: Wed, 2 Feb 2022 09:08:03 +0530 Subject: [PATCH 094/151] feat: add test --- .../email/doctype/email_account/test_email_account.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frappe/email/doctype/email_account/test_email_account.py b/frappe/email/doctype/email_account/test_email_account.py index 6d26f9f070..d59de050c4 100644 --- a/frappe/email/doctype/email_account/test_email_account.py +++ b/frappe/email/doctype/email_account/test_email_account.py @@ -246,6 +246,16 @@ class TestEmailAccount(unittest.TestCase): with self.assertRaises(Exception): email_account.validate() + def test_append_to(self): + email_aacount = frappe.get_doc("Email Account", "_Test Email Account 1") + mail_content = self.get_test_mail(fname="incoming-2.raw") + + inbound_mail = InboundMail(mail_content, email_aacount, 12345, 1, 'ToDo') + communication = inbound_mail.process() + if inbound_mail.append_to == 'ToDo': + self.assertEqual(communication.reference_doctype, 'ToDo') + self.assertTrue(communication.reference_name) + class TestInboundMail(unittest.TestCase): @classmethod def setUpClass(cls): From dfffb2570420cab7021cf1b2a09c629d818af49d Mon Sep 17 00:00:00 2001 From: marination Date: Wed, 2 Feb 2022 10:16:31 +0530 Subject: [PATCH 095/151] fix: Import moment in datetime js for web portal - moment is imported via libs.bundle.js which is used in desk views - frappe's web bundle does not contain moment due to which, `undefined moment` breaks loading other js assets - for some odd reason, importing moment in the web bundle does not work, so this is a temporary fix for that --- frappe/public/js/frappe/utils/datetime.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/public/js/frappe/utils/datetime.js b/frappe/public/js/frappe/utils/datetime.js index 196bdf68a3..beffa11331 100644 --- a/frappe/public/js/frappe/utils/datetime.js +++ b/frappe/public/js/frappe/utils/datetime.js @@ -1,6 +1,8 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // MIT License. See license.txt +import moment from "moment/min/moment-with-locales.js"; + frappe.provide('frappe.datetime'); frappe.defaultDateFormat = "YYYY-MM-DD"; From f960fb70dcc49f6a9ceab7edeeab8b66dc04aa44 Mon Sep 17 00:00:00 2001 From: kamaljohnson Date: Wed, 2 Feb 2022 10:14:46 +0530 Subject: [PATCH 096/151] fix: append_to in case of not passed issue --- frappe/email/receive.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 30dd146fa7..7b006c32b0 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -587,7 +587,7 @@ class InboundMail(Email): super().__init__(content) self.email_account = email_account self.uid = uid or -1 - self.append_to = append_to or -1 + self.append_to = append_to self.seen_status = seen_status or 0 # System documents related to this mail @@ -625,8 +625,8 @@ class InboundMail(Email): if self.parent_communication(): data['in_reply_to'] = self.parent_communication().name - if self.email_account.use_imap and self.append_to: - if self.append_to != 'Communication': + if self.email_account.use_imap: + if self.append_to and self.append_to != 'Communication': reference_doc = self._create_reference_document(self.append_to) if reference_doc: data['reference_doctype'] = reference_doc.doctype From 12442726c6e6627523d2ad1430fb5cc4c72f8efc Mon Sep 17 00:00:00 2001 From: kamaljohnson Date: Wed, 2 Feb 2022 11:00:39 +0530 Subject: [PATCH 097/151] fix: reply emails get_reference_doc from communication issue --- frappe/email/receive.py | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 7b006c32b0..8bbc4611ff 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -624,25 +624,18 @@ class InboundMail(Email): if self.parent_communication(): data['in_reply_to'] = self.parent_communication().name + + append_to = self.append_to if self.email_account.use_imap else self.email_account.append_to - if self.email_account.use_imap: - if self.append_to and self.append_to != 'Communication': - reference_doc = self._create_reference_document(self.append_to) - if reference_doc: - data['reference_doctype'] = reference_doc.doctype - data['reference_name'] = reference_doc.name - data['is_first'] = True - else: - if self.reference_document(): - data['reference_doctype'] = self.reference_document().doctype - data['reference_name'] = self.reference_document().name - elif self.email_account.append_to and self.email_account.append_to != 'Communication': - reference_doc = self._create_reference_document(self.email_account.append_to) - # TODO: here instead of using email_account.append_to, the imap_folder.append_to should be used - if reference_doc: - data['reference_doctype'] = reference_doc.doctype - data['reference_name'] = reference_doc.name - data['is_first'] = True + if self.reference_document(): + data['reference_doctype'] = self.reference_document().doctype + data['reference_name'] = self.reference_document().name + elif append_to and append_to != 'Communication': + reference_doc = self._create_reference_document(append_to) + if reference_doc: + data['reference_doctype'] = reference_doc.doctype + data['reference_name'] = reference_doc.name + data['is_first'] = True if self.is_notification(): # Disable notifications for notification. From ffbf215070b07439f80e78a70a817ef69066c48b Mon Sep 17 00:00:00 2001 From: kamaljohnson Date: Wed, 2 Feb 2022 11:29:43 +0530 Subject: [PATCH 098/151] fix: communication if is_first and no append_to issue --- frappe/email/receive.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 8bbc4611ff..b8156d5d9b 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -624,18 +624,19 @@ class InboundMail(Email): if self.parent_communication(): data['in_reply_to'] = self.parent_communication().name - + append_to = self.append_to if self.email_account.use_imap else self.email_account.append_to if self.reference_document(): data['reference_doctype'] = self.reference_document().doctype data['reference_name'] = self.reference_document().name - elif append_to and append_to != 'Communication': - reference_doc = self._create_reference_document(append_to) - if reference_doc: - data['reference_doctype'] = reference_doc.doctype - data['reference_name'] = reference_doc.name - data['is_first'] = True + else: + if append_to and append_to != 'Communication': + reference_doc = self._create_reference_document(append_to) + if reference_doc: + data['reference_doctype'] = reference_doc.doctype + data['reference_name'] = reference_doc.name + data['is_first'] = True if self.is_notification(): # Disable notifications for notification. From 12c7f2ae2d974d969cacb6e054b326fd01e2f3f7 Mon Sep 17 00:00:00 2001 From: Summayya Date: Wed, 2 Feb 2022 11:47:50 +0530 Subject: [PATCH 099/151] feat: add breadcrumbs in dashboard view --- frappe/core/page/dashboard_view/dashboard_view.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frappe/core/page/dashboard_view/dashboard_view.js b/frappe/core/page/dashboard_view/dashboard_view.js index e8e9cc9502..95bf1640a5 100644 --- a/frappe/core/page/dashboard_view/dashboard_view.js +++ b/frappe/core/page/dashboard_view/dashboard_view.js @@ -30,6 +30,7 @@ class Dashboard { show() { this.route = frappe.get_route(); + this.set_breadcrumbs(); if (this.route.length > 1) { // from route this.show_dashboard(this.route.slice(-1)[0]); @@ -75,6 +76,10 @@ class Dashboard { frappe.last_dashboard = current_dashboard_name; } + set_breadcrumbs() { + frappe.breadcrumbs.add("Desk", "Dashboard") + } + refresh() { frappe.run_serially([ () => this.render_cards(), From d5f5b07435382cd665cb657d3b57774fe1137612 Mon Sep 17 00:00:00 2001 From: Summayya Date: Wed, 2 Feb 2022 11:48:31 +0530 Subject: [PATCH 100/151] refactor: add condition for dashboard-view --- frappe/public/js/frappe/views/breadcrumbs.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frappe/public/js/frappe/views/breadcrumbs.js b/frappe/public/js/frappe/views/breadcrumbs.js index ae0a2edcda..4026f9b47b 100644 --- a/frappe/public/js/frappe/views/breadcrumbs.js +++ b/frappe/public/js/frappe/views/breadcrumbs.js @@ -70,6 +70,9 @@ frappe.breadcrumbs = { this.set_form_breadcrumb(breadcrumbs, view); } else if (breadcrumbs.doctype && view === 'list') { this.set_list_breadcrumb(breadcrumbs); + } else if (breadcrumbs.doctype && view == 'dashboard-view') { + this.set_list_breadcrumb(breadcrumbs); + this.set_dashboard_breadcrumb(breadcrumbs); } } @@ -164,6 +167,14 @@ frappe.breadcrumbs = { }, + set_dashboard_breadcrumb(breadcrumbs) { + const doctype = breadcrumbs.doctype; + const docname = frappe.get_route()[1]; + let dashboard_route = `/app/${frappe.router.slug(doctype)}/${docname}`; + $(`
  • ${__(docname)}
  • `) + .appendTo(this.$breadcrumbs); + }, + setup_modules() { if (!frappe.visible_modules) { frappe.visible_modules = $.map(frappe.boot.allowed_workspaces, (m) => { From 85680cc93dde05f82ef0b3dcd8f3f029d79291d8 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 2 Feb 2022 12:14:26 +0530 Subject: [PATCH 101/151] chore: better error message --- frappe/core/doctype/doctype/doctype.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index a439458ec7..bf62ece69c 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -701,9 +701,10 @@ class DocType(Document): # a Doctype name is the tablename created in database # `tab` the length of tablename is limited to 64 characters - if len(name) > (frappe.db.MAX_COLUMN_LENGTH - 3): + max_length = frappe.db.MAX_COLUMN_LENGTH - 3 + if len(name) > max_length: # length(tab + ) should be equal to 64 characters hence doctype should be 61 characters - frappe.throw(_("Doctype name is limited to 61 characters ({0})").format(name)) + frappe.throw(_("Doctype name is limited to {0} characters ({1})").format(max_length, name)) flags = {"flags": re.ASCII} From 962a3736aa01f5944e55c96fd3c63960f3b71af4 Mon Sep 17 00:00:00 2001 From: Summayya Date: Wed, 2 Feb 2022 12:22:49 +0530 Subject: [PATCH 102/151] refactor: add semicolon --- frappe/core/page/dashboard_view/dashboard_view.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/page/dashboard_view/dashboard_view.js b/frappe/core/page/dashboard_view/dashboard_view.js index 95bf1640a5..bf9fb2a286 100644 --- a/frappe/core/page/dashboard_view/dashboard_view.js +++ b/frappe/core/page/dashboard_view/dashboard_view.js @@ -77,7 +77,7 @@ class Dashboard { } set_breadcrumbs() { - frappe.breadcrumbs.add("Desk", "Dashboard") + frappe.breadcrumbs.add("Desk", "Dashboard"); } refresh() { From 001075f88aac2b3fc416972b9fd07840ac25df4e Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 2 Feb 2022 13:03:38 +0530 Subject: [PATCH 103/151] fix: Use lru_cache instead of cache * Mainly because it was introduced in PY39 * Making this change beacuse I'd like to evict cold caches too --- frappe/website/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/website/utils.py b/frappe/website/utils.py index 1772d8ada1..152d312533 100644 --- a/frappe/website/utils.py +++ b/frappe/website/utils.py @@ -4,7 +4,7 @@ import json import mimetypes import os import re -from functools import cache, wraps +from functools import lru_cache, wraps from typing import Dict, Optional import yaml @@ -512,7 +512,7 @@ def add_preload_headers(response): import traceback traceback.print_exc() -@cache +@lru_cache() def is_binary_file(path): # ref: https://stackoverflow.com/a/7392391/10309266 textchars = bytearray({7,8,9,10,12,13,27} | set(range(0x20, 0x100)) - {0x7f}) From c467708fbcf04be92f91b8f9fc2ba9dabce132ea Mon Sep 17 00:00:00 2001 From: Pruthvi Patel Date: Wed, 2 Feb 2022 14:57:28 +0530 Subject: [PATCH 104/151] fix: dynamic progress-bar color --- frappe/public/scss/desk/css_variables.scss | 2 ++ frappe/public/scss/desk/variables.scss | 3 +++ 2 files changed, 5 insertions(+) diff --git a/frappe/public/scss/desk/css_variables.scss b/frappe/public/scss/desk/css_variables.scss index 0912cb278b..a06ba3e9b0 100644 --- a/frappe/public/scss/desk/css_variables.scss +++ b/frappe/public/scss/desk/css_variables.scss @@ -54,4 +54,6 @@ $input-height: 28px !default; // skeleton --skeleton-bg: var(--gray-100); + // progress bar + --progress-bar-bg: var(--primary); } diff --git a/frappe/public/scss/desk/variables.scss b/frappe/public/scss/desk/variables.scss index 2855277ccd..abc63cd637 100644 --- a/frappe/public/scss/desk/variables.scss +++ b/frappe/public/scss/desk/variables.scss @@ -118,6 +118,9 @@ $custom-control-label-color: var(--text-color); $custom-switch-indicator-size: 8px; $custom-control-indicator-border-width: 2px; +// progress bar +$progress-bar-bg: var(--progress-bar-bg); + $navbar-nav-link-padding-x: 1rem !default; $navbar-padding-y: 1rem !default; $card-border-radius: 0.75rem !default; From eb6d7d2ea7da842ba477a53f0410d1b4e58a78db Mon Sep 17 00:00:00 2001 From: Pruthvi Patel Date: Wed, 2 Feb 2022 15:47:34 +0530 Subject: [PATCH 105/151] fix: dynamic colors for checkbox and radio --- frappe/public/scss/common/css_variables.scss | 1 + frappe/public/scss/common/global.scss | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frappe/public/scss/common/css_variables.scss b/frappe/public/scss/common/css_variables.scss index f4794362d3..b8b7f869fa 100644 --- a/frappe/public/scss/common/css_variables.scss +++ b/frappe/public/scss/common/css_variables.scss @@ -249,6 +249,7 @@ --checkbox-right-margin: var(--margin-xs); --checkbox-size: 14px; --checkbox-focus-shadow: 0 0 0 2px var(--gray-300); + --checkbox-gradient: linear-gradient(180deg, #4AC3F8 -124.51%, var(--primary) 100%); --right-arrow-svg: url("data: image/svg+xml;utf8, "); --left-arrow-svg: url("data: image/svg+xml;utf8, "); diff --git a/frappe/public/scss/common/global.scss b/frappe/public/scss/common/global.scss index 0324b75bfb..8a849ab51a 100644 --- a/frappe/public/scss/common/global.scss +++ b/frappe/public/scss/common/global.scss @@ -54,7 +54,7 @@ input[type="radio"] { } &:checked::before { - background-color: var(--blue-500); + background-color: var(--primary); border-radius: 16px; box-shadow: inset 0 0 0 2px white; } @@ -85,8 +85,8 @@ input[type="checkbox"] { } &:checked { - background-color: var(--blue-500); - background-image: $check-icon, linear-gradient(180deg, #4AC3F8 -124.51%, #2490EF 100%); + background-color: var(--primary); + background-image: $check-icon, var(--checkbox-gradient); background-size: 57%, 100%; box-shadow: none; border: none; From 2b4cb2e1c7a4ca273d7b31c8902a7200dc463183 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 2 Feb 2022 19:45:10 +0530 Subject: [PATCH 106/151] test: Add tests for get_time, get_timedelta --- frappe/tests/test_utils.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 507722f9e9..9a93a515c7 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -17,7 +17,7 @@ import frappe from frappe.utils import ceil, evaluate_filters, floor, format_timedelta from frappe.utils import get_url, money_in_words, parse_timedelta, scrub_urls from frappe.utils import validate_email_address, validate_url -from frappe.utils.data import cast, validate_python_code +from frappe.utils.data import cast, get_time, get_timedelta, nowtime, now_datetime, validate_python_code from frappe.utils.diff import _get_value_from_version, get_version_diff, version_query from frappe.utils.image import optimize_image, strip_exif_data from frappe.utils.response import json_handler @@ -334,6 +334,34 @@ class TestDateUtils(unittest.TestCase): self.assertEqual(frappe.utils.get_last_day_of_week("2020-12-28"), frappe.utils.getdate("2021-01-02")) + def test_get_time(self): + from dateutil.parser import ParserError + datetime_input = now_datetime() + timedelta_input = get_timedelta() + time_input = nowtime() + + self.assertIsInstance(get_time(datetime_input), time) + self.assertIsInstance(get_time(timedelta_input), time) + self.assertIsInstance(get_time(time_input), time) + self.assertIsInstance(get_time(str(datetime_input)), time) + self.assertIsInstance(get_time(str(timedelta_input)), time) + self.assertIsInstance(get_time(str(time_input)), time) + with self.assertRaises(ParserError): + get_time("100:0:0") + + def test_get_timedelta(self): + datetime_input = now_datetime() + timedelta_input = get_timedelta() + time_input = nowtime() + + self.assertIsInstance(get_timedelta(), timedelta) + self.assertIsInstance(get_timedelta("100:2:12"), timedelta) + self.assertIsInstance(get_timedelta("17:21:00"), timedelta) + self.assertIsInstance(get_timedelta("2012-01-19 17:21:00"), timedelta) + self.assertIsInstance(get_timedelta(str(datetime_input)), timedelta) + self.assertIsInstance(get_timedelta(str(timedelta_input)), timedelta) + self.assertIsInstance(get_timedelta(str(time_input)), timedelta) + class TestResponse(unittest.TestCase): def test_json_handler(self): class TEST(Enum): From ad1fedf4a8ff72aab6b2c4600ffc7e9d71c87b42 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 2 Feb 2022 19:46:09 +0530 Subject: [PATCH 107/151] fix: Return correct types for time utils --- frappe/utils/data.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 23a271c77c..34ddc23155 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -112,9 +112,9 @@ def get_timedelta(time: Optional[str] = None) -> Optional[datetime.timedelta]: try: t = parser.parse(time) except ParserError as e: - if "day" in e.args[1]: - from frappe.utils import parse_timedelta + if "day" in e.args[1] or "hour must be in" in e.args[0]: return parse_timedelta(time) + raise e return datetime.timedelta( hours=t.hour, minutes=t.minute, seconds=t.second, microseconds=t.microsecond ) @@ -329,17 +329,24 @@ def get_year_ending(date): # last day of this month return add_to_date(date, days=-1) -def get_time(time_str): +def get_time(time_str: str) -> datetime.time: from dateutil import parser + from dateutil.parser import ParserError if isinstance(time_str, datetime.datetime): return time_str.time() elif isinstance(time_str, datetime.time): return time_str - else: - if isinstance(time_str, datetime.timedelta): - return format_timedelta(time_str) + elif isinstance(time_str, datetime.timedelta): + return (datetime.datetime.min + time_str).time() + try: return parser.parse(time_str).time() + except ParserError as e: + if "day" in e.args[1] or "hour must be in" in e.args[0]: + return ( + datetime.datetime.min + parse_timedelta(time_str) + ).time() + raise e def get_datetime_str(datetime_obj): if isinstance(datetime_obj, str): From 1b7fa5e180ffd18693e9ef033b9dba8197d64baf Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 2 Feb 2022 20:22:43 +0530 Subject: [PATCH 108/151] fix(test): Update test according to API behaviour --- frappe/tests/test_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 9a93a515c7..27d1f7651d 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -335,7 +335,6 @@ class TestDateUtils(unittest.TestCase): frappe.utils.getdate("2021-01-02")) def test_get_time(self): - from dateutil.parser import ParserError datetime_input = now_datetime() timedelta_input = get_timedelta() time_input = nowtime() @@ -343,11 +342,10 @@ class TestDateUtils(unittest.TestCase): self.assertIsInstance(get_time(datetime_input), time) self.assertIsInstance(get_time(timedelta_input), time) self.assertIsInstance(get_time(time_input), time) + self.assertIsInstance(get_time("100:2:12"), time) self.assertIsInstance(get_time(str(datetime_input)), time) self.assertIsInstance(get_time(str(timedelta_input)), time) self.assertIsInstance(get_time(str(time_input)), time) - with self.assertRaises(ParserError): - get_time("100:0:0") def test_get_timedelta(self): datetime_input = now_datetime() From 3f6eb5d99db8669118d6a5e4f79ed223f6f29e63 Mon Sep 17 00:00:00 2001 From: marination Date: Wed, 2 Feb 2022 20:30:13 +0530 Subject: [PATCH 109/151] fix: Load `moment` and bind to window from new file - Introduce new file in `/lib` to import moment and bind to window - Use new lib file to load moment in web bundle and libs bundle (desk) Co-authored-by: Suraj Shetty --- frappe/public/js/frappe-web.bundle.js | 1 + frappe/public/js/frappe/utils/datetime.js | 2 -- frappe/public/js/lib/moment.js | 5 +++++ frappe/public/js/libs.bundle.js | 5 +---- 4 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 frappe/public/js/lib/moment.js diff --git a/frappe/public/js/frappe-web.bundle.js b/frappe/public/js/frappe-web.bundle.js index c962457964..b8d4006090 100644 --- a/frappe/public/js/frappe-web.bundle.js +++ b/frappe/public/js/frappe-web.bundle.js @@ -2,6 +2,7 @@ import "./jquery-bootstrap"; import "./frappe/class.js"; import "./frappe/polyfill.js"; import "./lib/md5.min.js"; +import "./lib/moment.js"; import "./frappe/provide.js"; import "./frappe/format.js"; import "./frappe/utils/number_format.js"; diff --git a/frappe/public/js/frappe/utils/datetime.js b/frappe/public/js/frappe/utils/datetime.js index beffa11331..196bdf68a3 100644 --- a/frappe/public/js/frappe/utils/datetime.js +++ b/frappe/public/js/frappe/utils/datetime.js @@ -1,8 +1,6 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // MIT License. See license.txt -import moment from "moment/min/moment-with-locales.js"; - frappe.provide('frappe.datetime'); frappe.defaultDateFormat = "YYYY-MM-DD"; diff --git a/frappe/public/js/lib/moment.js b/frappe/public/js/lib/moment.js new file mode 100644 index 0000000000..7a817a36cd --- /dev/null +++ b/frappe/public/js/lib/moment.js @@ -0,0 +1,5 @@ +// This file is used to make sure that `moment` is bound to the window +// before the bundle finishes loading, due to imports (datetime.js) in the bundle +// that depend on `moment`. +import momentTimezone from "moment-timezone/builds/moment-timezone-with-data.js"; +window.moment = momentTimezone; diff --git a/frappe/public/js/libs.bundle.js b/frappe/public/js/libs.bundle.js index 876d76875b..b71cc592a0 100644 --- a/frappe/public/js/libs.bundle.js +++ b/frappe/public/js/libs.bundle.js @@ -1,15 +1,12 @@ import "./jquery-bootstrap"; import Vue from "vue/dist/vue.esm.js"; -import moment from "moment/min/moment-with-locales.js"; -import momentTimezone from "moment-timezone/builds/moment-timezone-with-data.js"; +import "./lib/moment"; import io from "socket.io-client/dist/socket.io.slim.js"; import Sortable from "./lib/Sortable.min.js"; // TODO: esbuild // Don't think jquery.hotkeys is being used anywhere. Will remove this after being sure. // import "./lib/jquery/jquery.hotkeys.js"; -window.moment = moment; -window.moment = momentTimezone; window.Vue = Vue; window.Sortable = Sortable; window.io = io; From 749ace654c96d29f325c52197b05571d47d0baf8 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 2 Feb 2022 21:19:44 +0530 Subject: [PATCH 110/151] test: added unit test --- frappe/core/doctype/doctype/doctype.py | 2 +- frappe/core/doctype/doctype/test_doctype.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index bf62ece69c..67c31b704d 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -704,7 +704,7 @@ class DocType(Document): max_length = frappe.db.MAX_COLUMN_LENGTH - 3 if len(name) > max_length: # length(tab + ) should be equal to 64 characters hence doctype should be 61 characters - frappe.throw(_("Doctype name is limited to {0} characters ({1})").format(max_length, name)) + frappe.throw(_("Doctype name is limited to {0} characters ({1})").format(max_length, name), frappe.NameError) flags = {"flags": re.ASCII} diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 12c227464d..66858f2028 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -23,6 +23,7 @@ class TestDocType(unittest.TestCase): self.assertRaises(frappe.NameError, new_doctype("_Some 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 with a name whose length is more than 61 characters").insert) for name in ("Some DocType", "Some_DocType"): if frappe.db.exists("DocType", name): frappe.delete_doc("DocType", name) From 93c476ef30224529f8b3b4dc853fb3ba848aa061 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 2 Feb 2022 23:14:41 +0530 Subject: [PATCH 111/151] fix: Test case --- frappe/core/doctype/data_import/test_importer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py index d09b4f69e7..d024f950c8 100644 --- a/frappe/core/doctype/data_import/test_importer.py +++ b/frappe/core/doctype/data_import/test_importer.py @@ -67,9 +67,9 @@ class TestImporter(unittest.TestCase): order_by="log_index") self.assertEqual(frappe.parse_json(import_log[0]['row_indexes']), [2,3]) - expected_error = "Error: Child 1 of DocType for Import Row #1: Value missing for: Child Title" + expected_error = "Error: Child 1 of DocType for Import Row #1: Value missing for: Child Title" self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[0]['messages'])[0])['message'], expected_error) - expected_error = "Error: Child 1 of DocType for Import Row #2: Value missing for: Child Title" + expected_error = "Error: Child 1 of DocType for Import Row #2: Value missing for: Child Title" self.assertEqual(frappe.parse_json(frappe.parse_json(import_log[0]['messages'])[1])['message'], expected_error) self.assertEqual(frappe.parse_json(import_log[1]['row_indexes']), [4]) From 977c9d1622b7d9f3383c21817c60c87cf8d4375e Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 2 Feb 2022 23:28:05 +0530 Subject: [PATCH 112/151] chore: Remove unused imports --- frappe/core/doctype/data_import/data_import.py | 1 - frappe/core/doctype/data_import/test_importer.py | 1 - 2 files changed, 2 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.py b/frappe/core/doctype/data_import/data_import.py index 0c8ba072a1..5972e79b4d 100644 --- a/frappe/core/doctype/data_import/data_import.py +++ b/frappe/core/doctype/data_import/data_import.py @@ -12,7 +12,6 @@ from frappe.model.document import Document from frappe.modules.import_file import import_file_by_path from frappe.utils.background_jobs import enqueue from frappe.utils.csvutils import validate_google_sheets_url -from frappe.utils import cint class DataImport(Document): diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py index d024f950c8..3f78594dd2 100644 --- a/frappe/core/doctype/data_import/test_importer.py +++ b/frappe/core/doctype/data_import/test_importer.py @@ -3,7 +3,6 @@ # License: MIT. See LICENSE import unittest import frappe -import json from frappe.core.doctype.data_import.importer import Importer from frappe.utils import getdate, format_duration From ab5f45a96fe5f6ae95a76b81831d61838aa2e267 Mon Sep 17 00:00:00 2001 From: marination Date: Thu, 3 Feb 2022 11:31:18 +0530 Subject: [PATCH 113/151] chore: Add `moment` to bundle instead of injection via script tag - Wherever moment is imported in html, use dependent bundle to import it instead for consistency - Remove moment import from `base.html` since it uses `frappe-web.bundle.js` anyway --- frappe/public/js/web_form.bundle.js | 1 + frappe/templates/base.html | 2 -- .../website/doctype/web_form/templates/web_form.html | 10 ++-------- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/frappe/public/js/web_form.bundle.js b/frappe/public/js/web_form.bundle.js index 01969a489c..ffb7b824bd 100644 --- a/frappe/public/js/web_form.bundle.js +++ b/frappe/public/js/web_form.bundle.js @@ -1,2 +1,3 @@ +import "./lib/moment.js"; import "./frappe/utils/datetime.js"; import "./frappe/web_form/webform_script.js"; diff --git a/frappe/templates/base.html b/frappe/templates/base.html index bc1f802cf7..8d892b5de6 100644 --- a/frappe/templates/base.html +++ b/frappe/templates/base.html @@ -105,8 +105,6 @@ // for backward compatibility of some libs frappe.sys_defaults = frappe.boot.sysdefaults; - - {{ include_script('frappe-web.bundle.js') }} {% endblock %} diff --git a/frappe/website/doctype/web_form/templates/web_form.html b/frappe/website/doctype/web_form/templates/web_form.html index a8666b55e9..72cdf07c59 100644 --- a/frappe/website/doctype/web_form/templates/web_form.html +++ b/frappe/website/doctype/web_form/templates/web_form.html @@ -92,18 +92,12 @@ $(".file-size").each(function() { }); {{ include_script("controls.bundle.js") }} -{% if is_list %} -{# web form list #} - - +{% if is_list %} {{ include_script("dialog.bundle.js") }} {{ include_script("web_form.bundle.js") }} {{ include_script("bootstrap-4-web.bundle.js") }} -{% else %} -{# web form #} +{% else %} {{ include_script("dialog.bundle.js") }} - -