From 726fcfdb796407e71c7a459a255b201417ef8c87 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sun, 25 Dec 2022 23:19:11 +0530 Subject: [PATCH 001/407] refactor: qb.engine - simplify - qb.engine.get_query -> qb.get_query - qb.engine.build_conditions -> qb.get_query --- frappe/__init__.py | 4 +- frappe/database/database.py | 22 +- frappe/database/query.py | 707 +++++++----------- .../desk/doctype/number_card/number_card.py | 2 +- frappe/desk/listview.py | 2 +- frappe/query_builder/__init__.py | 2 +- frappe/query_builder/functions.py | 2 +- frappe/query_builder/utils.py | 5 +- frappe/tests/test_db_query.py | 4 +- frappe/tests/test_query.py | 88 +-- frappe/utils/goal.py | 2 +- 11 files changed, 347 insertions(+), 493 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index 2d491ca068..6b9d157003 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -23,7 +23,7 @@ import click from werkzeug.local import Local, release_local from frappe.query_builder import ( - get_qb_engine, + get_query, get_query_builder, patch_query_aggregation, patch_query_execute, @@ -244,7 +244,7 @@ def init(site: str, sites_path: str = ".", new_site: bool = False) -> None: local.session = _dict() local.dev_server = _dev_server local.qb = get_query_builder(local.conf.db_type or "mariadb") - local.qb.engine = get_qb_engine() + local.qb.get_query = get_query setup_module_map() if not _qb_patched.get(local.conf.db_type): diff --git a/frappe/database/database.py b/frappe/database/database.py index dfcc9dfe58..acbd28c9d7 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -620,7 +620,7 @@ class Database: return [map(values.get, fields)] else: - r = frappe.qb.engine.get_query( + r = frappe.qb.get_query( "Singles", filters={"field": ("in", tuple(fields)), "doctype": doctype}, fields=["field", "value"], @@ -653,7 +653,7 @@ class Database: # Get coulmn and value of the single doctype Accounts Settings account_settings = frappe.db.get_singles_dict("Accounts Settings") """ - queried_result = frappe.qb.engine.get_query( + queried_result = frappe.qb.get_query( "Singles", filters={"doctype": doctype}, fields=["field", "value"], @@ -726,7 +726,7 @@ class Database: if cache and fieldname in self.value_cache[doctype]: return self.value_cache[doctype][fieldname] - val = frappe.qb.engine.get_query( + val = frappe.qb.get_query( table="Singles", filters={"doctype": doctype, "field": fieldname}, fields="value", @@ -766,10 +766,10 @@ class Database: distinct=False, limit=None, ): - query = frappe.qb.engine.get_query( + query = frappe.qb.get_query( table=doctype, filters=filters, - orderby=order_by, + order_by=order_by, for_update=for_update, fields=fields, distinct=distinct, @@ -795,7 +795,7 @@ class Database: as_dict=False, ): if names := list(filter(None, names)): - return frappe.qb.engine.get_query( + return frappe.qb.get_query( doctype, fields=field, filters=names, @@ -852,7 +852,7 @@ class Database: frappe.clear_document_cache(dt, dt) else: - query = frappe.qb.engine.build_conditions(table=dt, filters=dn, update=True) + query = frappe.qb.get_query(table=dt, filters=dn, update=True) if isinstance(dn, str): frappe.clear_document_cache(dt, dn) @@ -1017,9 +1017,9 @@ class Database: cache_count = frappe.cache().get_value(f"doctype:count:{dt}") if cache_count is not None: return cache_count - count = frappe.qb.engine.get_query( - table=dt, filters=filters, fields=Count("*"), distinct=distinct - ).run(debug=debug)[0][0] + count = frappe.qb.get_query(table=dt, filters=filters, fields=Count("*"), distinct=distinct).run( + debug=debug + )[0][0] if not filters and cache: frappe.cache().set_value(f"doctype:count:{dt}", count, expires_in_sec=86400) return count @@ -1160,7 +1160,7 @@ class Database: Doctype name can be passed directly, it will be pre-pended with `tab`. """ filters = filters or kwargs.get("conditions") - query = frappe.qb.engine.build_conditions(table=doctype, filters=filters).delete() + query = frappe.qb.get_query(table=doctype, filters=filters).delete() if "debug" not in kwargs: kwargs["debug"] = debug return query.run(**kwargs) diff --git a/frappe/database/query.py b/frappe/database/query.py index a9dab02744..88de7f7088 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Callable import sqlparse from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder +from pypika.queries import QueryBuilder import frappe from frappe import _ @@ -171,18 +172,16 @@ def table_from_string(table: str) -> "DocType": return frappe.qb.DocType(table_name=table_name) -def get_nested_set_hierarchy_result(hierarchy: str, field: str, table: str): - ref_doctype = table +def get_nested_set_hierarchy_result(doctype: str, name: str, hierarchy: str): + table = frappe.qb.DocType(doctype) try: - lft, rgt = ( - frappe.qb.from_(ref_doctype).select("lft", "rgt").where(Field("name") == field).run()[0] - ) + lft, rgt = frappe.qb.from_(table).select("lft", "rgt").where(table.name == name).run()[0] except IndexError: lft, rgt = None, None if hierarchy in ("descendants of", "not descendants of"): result = ( - frappe.qb.from_(ref_doctype) + frappe.qb.from_(table) .select(Field("name")) .where(Field("lft") > lft) .where(Field("rgt") < rgt) @@ -192,7 +191,7 @@ def get_nested_set_hierarchy_result(hierarchy: str, field: str, table: str): else: # Get ancestor elements of a DocType with a tree structure result = ( - frappe.qb.from_(ref_doctype) + frappe.qb.from_(table) .select(Field("name")) .where(Field("lft") < lft) .where(Field("rgt") > rgt) @@ -232,37 +231,67 @@ OPERATOR_MAP: dict[str, Callable] = { class Engine: tables: dict[str, str] = {} - @cached_property - def OPERATOR_MAP(self): - # default operators - all_operators = OPERATOR_MAP.copy() + def get_query( + self, + table: str, + fields: list | tuple | None = None, + filters: dict[str, str | int] | str | int | list[list | str | int] | None = None, + pluck: str | None = None, + order_by: str | None = None, + group_by: str | None = None, + limit: int | None = None, + offset: int | None = None, + distinct: bool = False, + for_update: bool = False, + update: bool = False, + into: bool = False, + ) -> MySQLQueryBuilder | PostgreSQLQueryBuilder: + # Clean up state before each query + self.is_mariadb = frappe.db.db_type == "mariadb" + self.is_postgres = frappe.db.db_type == "postgres" + self.tables = {} + self.implicit_joins = set() - # TODO: update with site-specific custom operators / removed previous buggy implementation - if frappe.get_hooks("filters_config"): - from frappe.utils.commands import warn + self.doctype = table + self.table = self.get_table(table) - warn( - "The 'filters_config' hook used to add custom operators is not yet implemented" - " in frappe.db.query engine. Use db_query (frappe.get_list) instead." - ) + if update: + self.query = frappe.qb.update(self.table) + elif into: + self.query = frappe.qb.into(self.table) + else: + self.query = frappe.qb.from_(self.table) - return all_operators + self.fields = self.parse_fields(fields) + if not self.fields: + self.fields = [getattr(self.table, pluck or "name")] - def get_condition(self, table: str | Table, **kwargs) -> frappe.qb: - """Get initial table object + for field in self.fields: + if isinstance(field, DynamicTableField): + self.query = field.apply(self.query) + else: + self.query = self.query.select(field) - Args: - table (str): DocType + self.apply_filters(filters) + self.apply_implicit_joins() + self.apply_order_by(order_by) - Returns: - frappe.qb: DocType with initial condition - """ - table_object = self.get_table(table) - if kwargs.get("update"): - return frappe.qb.update(table_object) - if kwargs.get("into"): - return frappe.qb.into(table_object) - return frappe.qb.from_(table_object) + if limit: + self.query = self.query.limit(limit) + + if offset: + self.query = self.query.offset(offset) + + if distinct: + self.query = self.query.distinct() + + if for_update: + self.query = self.query.for_update() + + if group_by: + self.query = self.query.groupby(group_by) + + return self.query def get_table(self, table_name: str | Table) -> Table: if isinstance(table_name, Table): @@ -272,178 +301,93 @@ class Engine: self.tables[table_name] = frappe.qb.DocType(table_name) return self.tables[table_name] - def criterion_query(self, table: str, criterion: Criterion, **kwargs) -> frappe.qb: - """Generate filters from Criterion objects - - Args: - table (str): DocType - criterion (Criterion): Filters - - Returns: - frappe.qb: condition object - """ - condition = self.add_conditions(self.get_condition(table, **kwargs), **kwargs) - return condition.where(criterion) - - def add_conditions(self, conditions: frappe.qb, **kwargs): - """Adding additional conditions - - Args: - conditions (frappe.qb): built conditions - - Returns: - conditions (frappe.qb): frappe.qb object - """ - if kwargs.get("orderby") and kwargs.get("orderby") != "KEEP_DEFAULT_ORDERING": - orderby = kwargs.get("orderby") - if isinstance(orderby, str) and len(orderby.split()) > 1: - for ordby in orderby.split(","): - if ordby := ordby.strip(): - orderby, order = change_orderby(ordby) - conditions = conditions.orderby(orderby, order=order) - else: - conditions = conditions.orderby(orderby, order=kwargs.get("order") or Order.desc) - - if kwargs.get("limit"): - conditions = conditions.limit(kwargs.get("limit")) - conditions = conditions.offset(kwargs.get("offset", 0)) - - if kwargs.get("distinct"): - conditions = conditions.distinct() - - if kwargs.get("for_update"): - conditions = conditions.for_update() - - if kwargs.get("groupby"): - conditions = conditions.groupby(kwargs.get("groupby")) - - return conditions - - def misc_query(self, table: str, filters: list | tuple = None, **kwargs): - """Build conditions using the given Lists or Tuple filters - - Args: - table (str): DocType - filters (Union[List, Tuple], optional): Filters. Defaults to None. - """ - conditions = self.get_condition(table, **kwargs) + def apply_filters( + self, filters: dict[str, str | int | list] | str | int | list[list] | None = None + ): if not filters: - return conditions - if isinstance(filters, list): - for f in filters: - if isinstance(f, (list, tuple)): - _operator = self.OPERATOR_MAP[f[-2].casefold()] - if len(f) == 4: - table_object = self.get_table(f[0]) - _field = table_object[f[1]] - else: - _field = Field(f[0]) - conditions = conditions.where(_operator(_field, f[-1])) - elif isinstance(f, dict): - conditions = self.dict_query(table, f, **kwargs) - else: - _operator = self.OPERATOR_MAP[filters[1].casefold()] - if not isinstance(filters[0], str): - conditions = self.make_function_for_filters(filters[0], filters[2]) - break - conditions = conditions.where(_operator(Field(filters[0]), filters[2])) - break + return - return self.add_conditions(conditions, **kwargs) - - def dict_query(self, table: str, filters: dict[str, str | int] = None, **kwargs) -> frappe.qb: - """Build conditions using the given dictionary filters - - Args: - table (str): DocType - filters (Dict[str, Union[str, int]], optional): Filters. Defaults to None. - - Returns: - frappe.qb: conditions object - """ - conditions = self.get_condition(table, **kwargs) - if isinstance(table, str): - table = frappe.qb.DocType(table) - if not filters: - conditions = self.add_conditions(conditions, **kwargs) - return conditions - - for key, value in filters.items(): - if isinstance(value, bool): - filters.update({key: str(int(value))}) - - filters = { - (self.get_function_object(k) if has_function(k) else k): v for k, v in filters.items() - } - for key in filters: - value = filters.get(key) - _operator = self.OPERATOR_MAP["="] - - if not isinstance(key, str): - conditions = conditions.where(self.make_function_for_filters(key, value)) - continue - # Nested set support - if isinstance(value, (list, tuple)): - if value[0] in self.OPERATOR_MAP["nested_set"]: - hierarchy, _field = value - result = get_nested_set_hierarchy_result(hierarchy, _field, table) - _operator = ( - self.OPERATOR_MAP["not in"] - if hierarchy in ("not ancestors of", "not descendants of") - else self.OPERATOR_MAP["in"] - ) - if result: - result = list(itertools.chain.from_iterable(result)) - conditions = conditions.where(_operator(getattr(table, key), result)) - else: - conditions = conditions.where(_operator(getattr(table, key), ("",))) - # Allow additional conditions - break - - _operator = self.OPERATOR_MAP[value[0].casefold()] - _value = value[1] if value[1] else ("",) - conditions = conditions.where(_operator(getattr(table, key), _value)) - else: - if value is not None: - conditions = conditions.where(_operator(getattr(table, key), value)) - else: - _table = conditions._from[0] - field = getattr(_table, key) - conditions = conditions.where(field.isnull()) - - return self.add_conditions(conditions, **kwargs) - - def build_conditions( - self, table: str, filters: dict[str, str | int] | str | int = None, **kwargs - ) -> frappe.qb: - """Build conditions for sql query - - Args: - filters (Union[Dict[str, Union[str, int]], str, int]): conditions in Dict - table (str): DocType - - Returns: - frappe.qb: frappe.qb conditions object - """ - if isinstance(filters, int) or isinstance(filters, str): + if isinstance(filters, (str, int)): filters = {"name": str(filters)} if isinstance(filters, Criterion): - criterion = self.criterion_query(table, filters, **kwargs) + self.query = self.query.where(filters) + + elif isinstance(filters, dict): + self.apply_dict_filters(filters) elif isinstance(filters, (list, tuple)): - criterion = self.misc_query(table, filters, **kwargs) + self.apply_list_filters(filters) + def apply_list_filters(self, filters: list[list]): + for filter in filters: + if len(filter) == 2: + field, value = filter + self._apply_filter(field, value) + elif len(filter) == 3: + field, operator, value = filter + self._apply_filter(field, value, operator) + elif len(filter) == 4: + doctype, field, operator, value = filter + self._apply_filter(field, value, operator, doctype) + + def apply_dict_filters(self, filters: dict[str, str | int | list]): + for key in filters: + value = filters.get(key) + self._apply_filter(key, value) + + def _apply_filter( + self, field: str, value: str | int | list | None, operator: str = "=", doctype: str | None = None + ): + _field = field + _value = value + _operator = operator + + if has_function(field): + _field = self.get_function_object(field) + elif not doctype or doctype == self.doctype: + _field = self.table[field] + elif doctype: + _field = self.get_table(doctype)[field] + + # keep track of implicit join if child table is referenced + if doctype and doctype != self.doctype: + meta = frappe.get_meta(doctype) + if meta.istable: + self.implicit_joins.add((doctype, "child")) + + if isinstance(_value, (str, int)): + _value = str(_value) + elif isinstance(_value, (list, tuple)): + _operator, _value = _value + elif isinstance(_value, bool): + _value = int(_value) + + if isinstance(_value, str) and has_function(_value): + _value = self.get_function_object(_value) + + # Nested set + if _operator in self.OPERATOR_MAP["nested_set"]: + hierarchy = _operator + docname = _value + result = get_nested_set_hierarchy_result(self.doctype, docname, hierarchy) + operator_fn = ( + self.OPERATOR_MAP["not in"] + if hierarchy in ("not ancestors of", "not descendants of") + else self.OPERATOR_MAP["in"] + ) + if result: + result = list(itertools.chain.from_iterable(result)) + self.query = self.query.where(operator_fn(_field, result)) + else: + self.query = self.query.where(operator_fn(_field, ("",))) + return + + operator_fn = self.OPERATOR_MAP[_operator.casefold()] + if _value is None and isinstance(_field, Field): + self.query = self.query.where(_field.isnull()) else: - criterion = self.dict_query(filters=filters, table=table, **kwargs) - - return criterion - - def make_function_for_filters(self, key, value: int | str): - value = list(value) - if isinstance(value[1], str) and has_function(value[1]): - value[1] = self.get_function_object(value[1]) - return OPERATOR_MAP[value[0].casefold()](key, value[1]) + self.query = self.query.where(operator_fn(_field, _value)) def get_function_object(self, field: str) -> "Function": """Expects field to look like 'SUM(*)' or 'name' or something similar. Returns PyPika Function object""" @@ -495,84 +439,12 @@ class Engine: # Fall back for functions not present in `SqlFunctions`` return Function(func, *_args, alias=alias or None) - def function_objects_from_string(self, fields): - fields = list(map(lambda str: str.strip(), COMMA_PATTERN.split(fields))) - return self.function_objects_from_list(fields=fields) - - def function_objects_from_list(self, fields): - functions = [] - for field in fields: - field = field.casefold() if (isinstance(field, str) and "`" not in field) else field - if not issubclass(type(field), Criterion): - if any([f"{func}(" in field for func in SQL_FUNCTIONS]) or "(" in field: - functions.append(field) - - return [self.get_function_object(function) for function in functions] - - def remove_string_functions(self, fields, function_objects): - """Remove string functions from fields which have already been converted to function objects""" - - def _remove_string_aliasing(function, fields: list | str): - if function.alias: - to_replace = " as " + function.alias.casefold() - if to_replace in fields: - fields = fields.replace(to_replace, "") - elif " as " + f"`{function.alias.casefold()}" in fields: - fields = fields.replace(" as " + f"`{function.alias.casefold()}`", "") - return fields - - for function in function_objects: - if isinstance(fields, str): - fields = _remove_string_aliasing(function, fields) - fields = BRACKETS_PATTERN.sub("", re.sub(function.name, "", fields, flags=re.IGNORECASE)) - # Check if only comma is left in fields after stripping functions. - if "," in fields and (len(fields.strip()) == 1): - fields = "" - else: - updated_fields = [] - for field in fields: - if isinstance(field, str): - field = _remove_string_aliasing(function, field) - substituted_string = ( - BRACKETS_PATTERN.sub("", field).strip().casefold() - if "`" not in field - else BRACKETS_PATTERN.sub("", field).strip() - ) - # This is done to avoid casefold of table name. - if substituted_string.casefold() == function.name.casefold(): - replaced_string = substituted_string.casefold().replace(function.name.casefold(), "") - else: - replaced_string = substituted_string.replace(function.name.casefold(), "") - updated_fields.append(replaced_string) - fields = [field for field in updated_fields if field] - return fields - - def get_fieldnames_from_child_table(self, doctype, fields): - # Hacky and flaky implementation of implicit joins. - # convert child_table.fieldname to `tabChild DocType`.`fieldname` - _fields = [] - for field in fields: - if "." in field and "tab" not in field: - alias = None - if " as " in field: - field, alias = field.split(" as ") - fieldname, linked_fieldname = field.split(".") - linked_doctype = frappe.get_meta(doctype).get_field(fieldname).options - - field = f"`tab{linked_doctype}`.`{linked_fieldname}`" - if alias: - field = f"{field} {alias}" - _fields.append(field) - return _fields - def sanitize_fields(self, fields: str | list | tuple): - is_mariadb = frappe.db.db_type == "mariadb" - def _sanitize_field(field: str): if not isinstance(field, str): return field stripped_field = sqlparse.format(field, strip_comments=True, keyword_case="lower") - if is_mariadb: + if self.is_mariadb: return MARIADB_SPECIFIC_COMMENT.sub("", stripped_field) return stripped_field @@ -583,174 +455,88 @@ class Engine: return fields - def get_list_fields(self, table: str, fields: list) -> list: - updated_fields = [] - if issubclass(type(fields), Criterion) or "*" in fields: - return fields - fields = self.get_fieldnames_from_child_table(doctype=table, fields=fields) - for field in fields: - if not isinstance(field, Criterion) and field: - if " as " in field: - field, reference = field.split(" as ") - if "`" in field: - updated_fields.append(PseudoColumnMapper(f"{field} {reference}")) - else: - updated_fields.append(Field(field.strip()).as_(reference)) - elif "`" in str(field): - updated_fields.append(PseudoColumnMapper(field.strip())) - else: - updated_fields.append(Field(field)) - return updated_fields + def parse_string_field(self, field: str): + if field == "*": + return self.table.star + alias = None + if " as " in field: + field, alias = field.split(" as ") + if "`" in field: + if alias: + return PseudoColumnMapper(f"{field} {alias}") + return PseudoColumnMapper(field) + if alias: + return self.table[field].as_(alias) + return self.table[field] - def get_string_fields(self, fields: str) -> Field: - if fields == "*": - return fields - if "`" in fields: - fields = PseudoColumnMapper(fields) - if " as " in str(fields): - fields, reference = str(fields).split(" as ") - if "`" in str(fields): - fields = PseudoColumnMapper(f"{fields} {reference}") - else: - fields = Field(fields).as_(reference) - return fields - - def set_fields(self, table: str, fields, **kwargs) -> list: - fields = kwargs.get("pluck") if kwargs.get("pluck") else fields or "name" + def parse_fields(self, fields: str | list | tuple | None) -> list: + if not fields: + return [] fields = self.sanitize_fields(fields) - if isinstance(fields, list) and None in fields and Field not in fields: - return None - function_objects = [] - is_list = isinstance(fields, (list, tuple, set)) - if is_list and len(fields) == 1: - fields = fields[0] - is_list = False + if isinstance(fields, (list, tuple, set)) and None in fields and Field not in fields: + return [] - if is_list: - function_objects += self.function_objects_from_list(fields=fields) + if not isinstance(fields, (list, tuple)): + fields = [fields] - is_str = isinstance(fields, str) - if is_str: - fields = fields.casefold() if "`" not in fields else fields - function_objects += self.function_objects_from_string(fields=fields) - - fields = self.remove_string_functions(fields, function_objects) - - if is_str and "," in fields: - fields = [field.replace(" ", "") if "as" not in field else field for field in fields.split(",")] - is_list, is_str = True, False - - if is_str: - fields = self.get_string_fields(fields) - if not is_str and fields: - fields = self.get_list_fields(table, fields) - - # Need to check instance again since fields modified. - if not isinstance(fields, (list, tuple, set)): - fields = [fields] if fields else [] - - fields.extend(function_objects) - return fields - - def join_child_tables( - self, - criterion: Criterion, - join_type: str, - child_table: Table, - parent_table: Table, - ) -> Criterion: - if self.joined_tables.get(join_type) != child_table: - criterion = getattr(criterion, join_type)(child_table).on( - (child_table.parent == parent_table.name) - & (child_table.parenttype == TAB_PATTERN.sub("", parent_table._table_name)) - ) - self.joined_tables[join_type] = child_table - return criterion - - def join(self, criterion, fields, table, join_type): - """Handles all join operations on criterion objects""" - has_join = False - table_pattern = ( - re.compile(r"`\btab\w+") if frappe.db.db_type == "mariadb" else re.compile(r'"\btab\w+') - ) - - def _update_pypika_fields(field): - if not is_pypika_function_object(field): - field = field if isinstance(field, (str, PseudoColumnMapper)) else field.get_sql() - if not table_pattern.search(str(field)): - if isinstance(field, PseudoColumnMapper): - field = field.get_sql() - return getattr(frappe.qb.DocType(table), field) - else: - return field + def parse_field(field: str): + if has_function(field): + return self.get_function_object(field) + elif parsed := DynamicTableField.parse(field, self.doctype): + return parsed else: - field.args = [getattr(frappe.qb.DocType(table), arg.get_sql()) for arg in field.args] - return field + return self.parse_string_field(field) - if not isinstance(fields, Criterion): - for field in fields: - # Only perform this bit if foreign doctype in fields - if ( - not is_pypika_function_object(field) - and (str(field).startswith('"tab') or str(field).startswith("`tab")) - and (f"`tab{table}`" not in str(field) and f'tab{table}"' not in str(field)) - ): - has_join = True - child_table = table_from_string(str(field)) - parent_table = frappe.qb.DocType(table) if not isinstance(table, Table) else table - criterion = self.join_child_tables( - criterion=criterion, - join_type=join_type, - child_table=child_table, - parent_table=parent_table, - ) + _fields = [] + for field in fields: + if isinstance(field, Criterion): + _fields.append(field) + elif isinstance(field, str): + if "," in field: + field = field.casefold() if "`" not in field else field + field_list = COMMA_PATTERN.split(field) + for field in field_list: + if _field := field.strip(): + _fields.append(parse_field(_field)) + else: + _fields.append(parse_field(field)) - if has_join: - fields = [_update_pypika_fields(field) for field in fields] + return _fields - if len(self.tables) > 1: - parent_table = self.tables[table] - child_tables = list(self.tables.values())[1:] - for child_table in child_tables: - criterion = self.join_child_tables( - criterion, - join_type=join_type, - child_table=child_table, - parent_table=parent_table, + def apply_implicit_joins(self): + for d in self.implicit_joins: + doctype, join_type = d + table = self.get_table(doctype) + if join_type == "child": + self.query = self.query.left_join(table).on( + (table.parent == self.table.name) & (table.parenttype == self.doctype) ) - return criterion, fields + def apply_order_by(self, order_by: str | None): + if not order_by or order_by == "KEEP_DEFAULT_ORDERING": + return + for declaration in order_by.split(","): + if _order_by := declaration.strip(): + parts = _order_by.split(" ") + order_field, order_direction = parts[0], parts[1] if len(parts) > 1 else "asc" + order_direction = Order.asc if order_direction.lower() == "asc" else Order.desc + self.query = self.query.orderby(order_field, order=order_direction) - def get_query( - self, - table: str, - fields: list | tuple, - filters: dict[str, str | int] | str | int | list[list | str | int] = None, - **kwargs, - ) -> MySQLQueryBuilder | PostgreSQLQueryBuilder: - # Clean up state before each query - self.tables = {} - self.joined_tables = {} - self.linked_doctype = None - self.fieldname = None + @cached_property + def OPERATOR_MAP(self): + # default operators + all_operators = OPERATOR_MAP.copy() - criterion = self.build_conditions(table, filters, **kwargs) - fields = self.set_fields(table, fields, **kwargs) - join_type = kwargs.get("join").replace(" ", "_") if kwargs.get("join") else "left_join" - criterion, fields = self.join( - criterion=criterion, fields=fields, table=table, join_type=join_type - ) + # TODO: update with site-specific custom operators / removed previous buggy implementation + if frappe.get_hooks("filters_config"): + from frappe.utils.commands import warn - if isinstance(fields, (list, tuple)): - query = criterion.select(*fields) + warn( + "The 'filters_config' hook used to add custom operators is not yet implemented" + " in frappe.db.query engine. Use db_query (frappe.get_list) instead." + ) - elif isinstance(fields, Criterion): - query = criterion.select(fields) - - else: - query = criterion.select(fields) - - return query + return all_operators class Permission: @@ -781,3 +567,80 @@ class Permission: @staticmethod def get_tables_from_query(query: str): return [table for table in WORDS_PATTERN.findall(query) if table.startswith("tab")] + + +class DynamicTableField: + def __init__( + self, + doctype: str, + fieldname: str, + parent_doctype: str, + alias: str | None = None, + ) -> None: + self.doctype = doctype + self.fieldname = fieldname + self.alias = alias + self.parent_doctype = parent_doctype + + def __str__(self) -> str: + table_name = f"`tab{self.doctype}`" + fieldname = f"`{self.fieldname}`" + if frappe.db.db_type == "postgres": + table_name = table_name.replace("`", '"') + fieldname = fieldname.replace("`", '"') + alias = f"AS {self.alias}" if self.alias else "" + return f"{table_name}.{fieldname} {alias}".strip() + + @staticmethod + def parse(field: str, doctype: str): + if "." in field: + alias = None + if " as " in field: + field, alias = field.split(" as ") + if field.startswith("`tab") or field.startswith('"tab'): + _, child_doctype, child_field = re.search(r'([`"])tab(.+?)\1.\1(.+)\1', field).groups() + if child_doctype == doctype: + return + return ChildTableField(child_doctype, child_field, doctype, alias=alias) + else: + linked_fieldname, fieldname = field.split(".") + linked_field = frappe.get_meta(doctype).get_field(linked_fieldname) + linked_doctype = linked_field.options + if linked_field.fieldtype == "Link": + return LinkTableField(linked_doctype, fieldname, doctype, linked_fieldname, alias=alias) + elif linked_field.fieldtype in frappe.model.table_fields: + return ChildTableField(linked_doctype, fieldname, doctype, alias=alias) + + def apply(self, query: QueryBuilder) -> QueryBuilder: + raise NotImplementedError + + +class ChildTableField(DynamicTableField): + def apply(self, query: QueryBuilder) -> QueryBuilder: + table = frappe.qb.DocType(self.doctype) + main_table = frappe.qb.DocType(self.parent_doctype) + if not query.is_joined(table): + query = query.left_join(table).on( + (table.parent == main_table.name) & (table.parenttype == self.parent_doctype) + ) + return query.select(getattr(table, self.fieldname).as_(self.alias or None)) + + +class LinkTableField(DynamicTableField): + def __init__( + self, + doctype: str, + fieldname: str, + parent_doctype: str, + link_fieldname: str, + alias: str | None = None, + ) -> None: + super().__init__(doctype, fieldname, parent_doctype, alias=alias) + self.link_fieldname = link_fieldname + + def apply(self, query: QueryBuilder) -> QueryBuilder: + table = frappe.qb.DocType(self.doctype) + main_table = frappe.qb.DocType(self.parent_doctype) + if not query.is_joined(table): + query = query.left_join(table).on(table.name == getattr(main_table, self.link_fieldname)) + return query.select(getattr(table, self.fieldname).as_(self.alias or None)) diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py index 8e808ff635..30ec99644a 100644 --- a/frappe/desk/doctype/number_card/number_card.py +++ b/frappe/desk/doctype/number_card/number_card.py @@ -200,7 +200,7 @@ def get_cards_for_user(doctype, txt, searchfield, start, page_len, filters): if txt: search_conditions = [numberCard[field].like(f"%{txt}%") for field in searchfields] - condition_query = frappe.qb.engine.build_conditions(doctype, filters) + condition_query = frappe.qb.get_query(doctype, filters) return ( condition_query.select(numberCard.name, numberCard.label, numberCard.document_type) diff --git a/frappe/desk/listview.py b/frappe/desk/listview.py index ea6eb6259c..8b514444df 100644 --- a/frappe/desk/listview.py +++ b/frappe/desk/listview.py @@ -36,7 +36,7 @@ def get_group_by_count(doctype: str, current_filters: str, field: str) -> list[d ToDo = DocType("ToDo") User = DocType("User") count = Count("*").as_("count") - filtered_records = frappe.qb.engine.build_conditions(doctype, current_filters).select("name") + filtered_records = frappe.qb.get_query(doctype, filters=current_filters).select("name") return ( frappe.qb.from_(ToDo) diff --git a/frappe/query_builder/__init__.py b/frappe/query_builder/__init__.py index eb1d9df08f..b1f242f78c 100644 --- a/frappe/query_builder/__init__.py +++ b/frappe/query_builder/__init__.py @@ -7,7 +7,7 @@ from frappe.query_builder.terms import ParameterizedFunction, ParameterizedValue from frappe.query_builder.utils import ( Column, DocType, - get_qb_engine, + get_query, get_query_builder, patch_query_aggregation, patch_query_execute, diff --git a/frappe/query_builder/functions.py b/frappe/query_builder/functions.py index 24e2ee0e5f..b1e4e7eff1 100644 --- a/frappe/query_builder/functions.py +++ b/frappe/query_builder/functions.py @@ -103,7 +103,7 @@ class Cast_(Function): def _aggregate(function, dt, fieldname, filters, **kwargs): return ( - frappe.qb.engine.build_conditions(dt, filters) + frappe.qb.get_query(dt, filters=filters) .select(function(PseudoColumn(fieldname))) .run(**kwargs)[0][0] or 0 diff --git a/frappe/query_builder/utils.py b/frappe/query_builder/utils.py index be0403a291..f80dd4fc33 100644 --- a/frappe/query_builder/utils.py +++ b/frappe/query_builder/utils.py @@ -3,6 +3,7 @@ from importlib import import_module from typing import Any, Callable, get_type_hints from pypika import Query +from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder from pypika.queries import Column from pypika.terms import PseudoColumn @@ -55,10 +56,10 @@ def get_query_builder(type_of_db: str) -> Postgres | MariaDB: return picks[db] -def get_qb_engine(): +def get_query(*args, **kwargs) -> MySQLQueryBuilder | PostgreSQLQueryBuilder: from frappe.database.query import Engine - return Engine() + return Engine().get_query(*args, **kwargs) def get_attr(method_string): diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 162d3e9d8a..1a3e8735dc 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -229,9 +229,7 @@ class TestReportview(FrappeTestCase): ) def test_none_filter(self): - query = frappe.qb.engine.get_query( - "DocType", fields="name", filters={"restrict_to_domain": None} - ) + query = frappe.qb.get_query("DocType", fields="name", filters={"restrict_to_domain": None}) sql = str(query).replace("`", "").replace('"', "") condition = "restrict_to_domain IS NULL" self.assertIn(condition, sql) diff --git a/frappe/tests/test_query.py b/frappe/tests/test_query.py index 3f48882345..957d76d022 100644 --- a/frappe/tests/test_query.py +++ b/frappe/tests/test_query.py @@ -56,30 +56,28 @@ class TestQuery(FrappeTestCase): @run_only_if(db_type_is.MARIADB) def test_multiple_tables_in_filters(self): self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( "DocType", ["*"], [ - ["BOM Update Log", "name", "like", "f%"], + ["DocField", "name", "like", "f%"], ["DocType", "parent", "=", "something"], ], ).get_sql(), - "SELECT * FROM `tabDocType` LEFT JOIN `tabBOM Update Log` ON `tabBOM Update Log`.`parent`=`tabDocType`.`name` AND `tabBOM Update Log`.`parenttype`='DocType' WHERE `tabBOM Update Log`.`name` LIKE 'f%' AND `tabDocType`.`parent`='something'", + "SELECT `tabDocType`.* FROM `tabDocType` LEFT JOIN `tabDocField` ON `tabDocField`.`parent`=`tabDocType`.`name` AND `tabDocField`.`parenttype`='DocType' WHERE `tabDocField`.`name` LIKE 'f%' AND `tabDocType`.`parent`='something'", ) @run_only_if(db_type_is.MARIADB) def test_string_fields(self): self.assertEqual( - frappe.qb.engine.get_query( - "User", fields="name, email", filters={"name": "Administrator"} - ).get_sql(), + frappe.qb.get_query("User", fields="name, email", filters={"name": "Administrator"}).get_sql(), frappe.qb.from_("User") .select(Field("name"), Field("email")) .where(Field("name") == "Administrator") .get_sql(), ) self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( "User", fields=["`name`, `email`"], filters={"name": "Administrator"} ).get_sql(), frappe.qb.from_("User") @@ -89,7 +87,7 @@ class TestQuery(FrappeTestCase): ) self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( "User", fields=["`tabUser`.`name`", "`tabUser`.`email`"], filters={"name": "Administrator"} ).run(), frappe.qb.from_("User") @@ -99,7 +97,7 @@ class TestQuery(FrappeTestCase): ) self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( "User", fields=["`tabUser`.`name` as owner", "`tabUser`.`email`"], filters={"name": "Administrator"}, @@ -111,7 +109,7 @@ class TestQuery(FrappeTestCase): ) self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( "User", fields=["`tabUser`.`name`, Count(`name`) as count"], filters={"name": "Administrator"} ).run(), frappe.qb.from_("User") @@ -121,7 +119,7 @@ class TestQuery(FrappeTestCase): ) self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( "User", fields=["`tabUser`.`name`, Count(`name`) as `count`"], filters={"name": "Administrator"}, @@ -133,7 +131,7 @@ class TestQuery(FrappeTestCase): ) self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( "User", fields="`tabUser`.`name`, Count(`name`) as `count`", filters={"name": "Administrator"} ).run(), frappe.qb.from_("User") @@ -144,38 +142,34 @@ class TestQuery(FrappeTestCase): def test_functions_fields(self): self.assertEqual( - frappe.qb.engine.get_query("User", fields="Count(name)", filters={}).get_sql(), + frappe.qb.get_query("User", fields="Count(name)", filters={}).get_sql(), frappe.qb.from_("User").select(Count(Field("name"))).get_sql(), ) self.assertEqual( - frappe.qb.engine.get_query("User", fields=["Count(name)", "Max(name)"], filters={}).get_sql(), + frappe.qb.get_query("User", fields=["Count(name)", "Max(name)"], filters={}).get_sql(), frappe.qb.from_("User").select(Count(Field("name")), Max(Field("name"))).get_sql(), ) self.assertEqual( - frappe.qb.engine.get_query( - "User", fields=["abs(name-email)", "Count(name)"], filters={} - ).get_sql(), + frappe.qb.get_query("User", fields=["abs(name-email)", "Count(name)"], filters={}).get_sql(), frappe.qb.from_("User") .select(Abs(Field("name") - Field("email")), Count(Field("name"))) .get_sql(), ) self.assertEqual( - frappe.qb.engine.get_query("User", fields=[Count("*")], filters={}).get_sql(), + frappe.qb.get_query("User", fields=[Count("*")], filters={}).get_sql(), frappe.qb.from_("User").select(Count("*")).get_sql(), ) self.assertEqual( - frappe.qb.engine.get_query( - "User", fields="timestamp(creation, modified)", filters={} - ).get_sql(), + frappe.qb.get_query("User", fields="timestamp(creation, modified)", filters={}).get_sql(), frappe.qb.from_("User").select(Timestamp(Field("creation"), Field("modified"))).get_sql(), ) self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( "User", fields="Count(name) as count, Max(email) as max_email", filters={} ).get_sql(), frappe.qb.from_("User") @@ -186,85 +180,83 @@ class TestQuery(FrappeTestCase): def test_qb_fields(self): user_doctype = frappe.qb.DocType("User") self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( user_doctype, fields=[user_doctype.name, user_doctype.email], filters={} ).get_sql(), frappe.qb.from_(user_doctype).select(user_doctype.name, user_doctype.email).get_sql(), ) self.assertEqual( - frappe.qb.engine.get_query(user_doctype, fields=user_doctype.email, filters={}).get_sql(), + frappe.qb.get_query(user_doctype, fields=user_doctype.email, filters={}).get_sql(), frappe.qb.from_(user_doctype).select(user_doctype.email).get_sql(), ) def test_aliasing(self): user_doctype = frappe.qb.DocType("User") self.assertEqual( - frappe.qb.engine.get_query( - user_doctype, fields=["name as owner", "email as id"], filters={} - ).get_sql(), + frappe.qb.get_query("User", fields=["name as owner", "email as id"], filters={}).get_sql(), frappe.qb.from_(user_doctype) .select(user_doctype.name.as_("owner"), user_doctype.email.as_("id")) .get_sql(), ) self.assertEqual( - frappe.qb.engine.get_query( - user_doctype, fields="name as owner, email as id", filters={} - ).get_sql(), + frappe.qb.get_query(user_doctype, fields="name as owner, email as id", filters={}).get_sql(), frappe.qb.from_(user_doctype) .select(user_doctype.name.as_("owner"), user_doctype.email.as_("id")) .get_sql(), ) self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( user_doctype, fields=["Count(name) as count", "email as id"], filters={} ).get_sql(), frappe.qb.from_(user_doctype) - .select(user_doctype.email.as_("id"), Count(Field("name")).as_("count")) + .select(Count(Field("name")).as_("count"), user_doctype.email.as_("id")) .get_sql(), ) @run_only_if(db_type_is.MARIADB) def test_filters(self): self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( "User", filters={"IfNull(name, " ")": ("<", Now())}, fields=["Max(name)"] ).run(), frappe.qb.from_("User").select(Max(Field("name"))).where(Ifnull("name", "") < Now()).run(), ) def test_implicit_join_query(self): + self.maxDiff = None + self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( "Note", filters={"name": "Test Note Title"}, fields=["name", "`tabNote Seen By`.`user` as seen_by"], ).get_sql(), - "SELECT `tabNote`.`name`,`tabNote Seen By`.`user` seen_by FROM `tabNote` LEFT JOIN `tabNote Seen By` ON `tabNote Seen By`.`parent`=`tabNote`.`name` AND `tabNote Seen By`.`parenttype`='Note' WHERE `tabNote`.`name`='Test Note Title'".replace( + "SELECT `tabNote`.`name`,`tabNote Seen By`.`user` `seen_by` FROM `tabNote` LEFT JOIN `tabNote Seen By` ON `tabNote Seen By`.`parent`=`tabNote`.`name` AND `tabNote Seen By`.`parenttype`='Note' WHERE `tabNote`.`name`='Test Note Title'".replace( "`", '"' if frappe.db.db_type == "postgres" else "`" ), ) self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( "Note", filters={"name": "Test Note Title"}, fields=["name", "`tabNote Seen By`.`user` as seen_by", "`tabNote Seen By`.`idx` as idx"], ).get_sql(), - "SELECT `tabNote`.`name`,`tabNote Seen By`.`user` seen_by,`tabNote Seen By`.`idx` idx FROM `tabNote` LEFT JOIN `tabNote Seen By` ON `tabNote Seen By`.`parent`=`tabNote`.`name` AND `tabNote Seen By`.`parenttype`='Note' WHERE `tabNote`.`name`='Test Note Title'".replace( + "SELECT `tabNote`.`name`,`tabNote Seen By`.`user` `seen_by`,`tabNote Seen By`.`idx` `idx` FROM `tabNote` LEFT JOIN `tabNote Seen By` ON `tabNote Seen By`.`parent`=`tabNote`.`name` AND `tabNote Seen By`.`parenttype`='Note' WHERE `tabNote`.`name`='Test Note Title'".replace( "`", '"' if frappe.db.db_type == "postgres" else "`" ), ) self.assertEqual( - frappe.qb.engine.get_query( + frappe.qb.get_query( "Note", filters={"name": "Test Note Title"}, fields=["name", "seen_by.user as seen_by", "`tabNote Seen By`.`idx` as idx"], ).get_sql(), - "SELECT `tabNote`.`name`,`tabNote Seen By`.`user` seen_by,`tabNote Seen By`.`idx` idx FROM `tabNote` LEFT JOIN `tabNote Seen By` ON `tabNote Seen By`.`parent`=`tabNote`.`name` AND `tabNote Seen By`.`parenttype`='Note' WHERE `tabNote`.`name`='Test Note Title'".replace( + "SELECT `tabNote`.`name`,`tabNote Seen By`.`user` `seen_by`,`tabNote Seen By`.`idx` `idx` FROM `tabNote` LEFT JOIN `tabNote Seen By` ON `tabNote Seen By`.`parent`=`tabNote`.`name` AND `tabNote Seen By`.`parenttype`='Note' WHERE `tabNote`.`name`='Test Note Title'".replace( "`", '"' if frappe.db.db_type == "postgres" else "`" ), ) @@ -272,40 +264,40 @@ class TestQuery(FrappeTestCase): @run_only_if(db_type_is.MARIADB) def test_comment_stripping(self): self.assertNotIn( - "email", frappe.qb.engine.get_query("User", fields=["name", "#email"], filters={}).get_sql() + "email", frappe.qb.get_query("User", fields=["name", "#email"], filters={}).get_sql() ) def test_nestedset(self): frappe.db.sql("delete from `tabDocType` where `name` = 'Test Tree DocType'") frappe.db.sql_ddl("drop table if exists `tabTest Tree DocType`") create_tree_docs() - descendants_result = frappe.qb.engine.get_query( + descendants_result = frappe.qb.get_query( "Test Tree DocType", fields=["name"], filters={"name": ("descendants of", "Parent 1")}, - orderby="modified", + order_by="modified", ).run(as_list=1) # Format decendants result descendants_result = list(itertools.chain.from_iterable(descendants_result)) self.assertListEqual(descendants_result, get_descendants_of("Test Tree DocType", "Parent 1")) - ancestors_result = frappe.qb.engine.get_query( + ancestors_result = frappe.qb.get_query( "Test Tree DocType", fields=["name"], filters={"name": ("ancestors of", "Child 2")}, - orderby="modified", + order_by="modified", ).run(as_list=1) # Format ancestors result ancestors_result = list(itertools.chain.from_iterable(ancestors_result)) self.assertListEqual(ancestors_result, get_ancestors_of("Test Tree DocType", "Child 2")) - not_descendants_result = frappe.qb.engine.get_query( + not_descendants_result = frappe.qb.get_query( "Test Tree DocType", fields=["name"], filters={"name": ("not descendants of", "Parent 1")}, - orderby="modified", + order_by="modified", ).run(as_dict=1) self.assertListEqual( @@ -317,11 +309,11 @@ class TestQuery(FrappeTestCase): ), ) - not_ancestors_result = frappe.qb.engine.get_query( + not_ancestors_result = frappe.qb.get_query( "Test Tree DocType", fields=["name"], filters={"name": ("not ancestors of", "Child 2")}, - orderby="modified", + order_by="modified", ).run(as_dict=1) self.assertListEqual( diff --git a/frappe/utils/goal.py b/frappe/utils/goal.py index 13c4633031..0dcadc5ec6 100644 --- a/frappe/utils/goal.py +++ b/frappe/utils/goal.py @@ -24,7 +24,7 @@ def get_monthly_results( date_format = "%m-%Y" if frappe.db.db_type != "postgres" else "MM-YYYY" return dict( - frappe.qb.engine.build_conditions(table=goal_doctype, filters=filters) + frappe.qb.get_query(table=goal_doctype, filters=filters) .select( DateFormat(Table[date_col], date_format).as_("month_year"), Function(aggregation, goal_field), From f79185d79ceeb3dbe74098996f5af01c00833dc2 Mon Sep 17 00:00:00 2001 From: Revant Nandgaonkar Date: Thu, 3 Nov 2022 15:08:26 +0000 Subject: [PATCH 002/407] feat: use Connected App for OAuth based Email Account --- .../doctype/email_account/email_account.js | 71 ++++++++++------- .../doctype/email_account/email_account.json | 38 ++++----- .../doctype/email_account/email_account.py | 48 ++++++------ frappe/email/oauth.py | 77 +------------------ .../doctype/connected_app/connected_app.py | 29 ++++++- .../doctype/token_cache/token_cache.py | 9 ++- 6 files changed, 126 insertions(+), 146 deletions(-) diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index 98160e5f46..92b7deece3 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -68,15 +68,19 @@ frappe.email_defaults_pop = { function oauth_access(frm) { return frappe.call({ - method: "frappe.email.oauth.oauth_access", + method: "frappe.client.get", args: { - email_account: frm.doc.name, - service: frm.doc.service || "", + doctype: "Connected App", + name: frm.doc.connected_app, }, - callback: function (r) { - if (!r.exc) { - window.open(r.message.url, "_self"); - } + callback: app => { + return frappe.call({ + method: "initiate_web_application_flow", + doc: app.message, + callback: function (r) { + window.open(r.message, "_self"); + }, + }); }, }); } @@ -105,7 +109,6 @@ frappe.ui.form.on("Email Account", { }); } frm.events.show_gmail_message_for_less_secure_apps(frm); - frm.events.toggle_auth_method(frm); }, use_imap: function (frm) { @@ -153,7 +156,6 @@ frappe.ui.form.on("Email Account", { frm.add_child("imap_folder", { folder_name: "INBOX" }); frm.refresh_field("imap_folder"); } - frm.toggle_display(["auth_method"], frm.doc.service === "GMail"); set_default_max_attachment_size(frm, "attachment_limit"); }, @@ -167,21 +169,25 @@ frappe.ui.form.on("Email Account", { delete frappe.route_flags.delete_user_from_locals; delete locals["User"][frappe.route_flags.linked_user]; } + + if (frm.doc.connected_app && !frm.doc.connected_user) { + frm.set_value("connected_user", frappe.session.user); + } }, after_save(frm) { - if (frm.doc.auth_method === "OAuth" && !frm.doc.refresh_token) { - oauth_access(frm); - } - }, - - toggle_auth_method: function (frm) { - if (frm.doc.service !== "GMail") { - frm.toggle_display(["auth_method"], false); - frm.doc.auth_method = "Basic"; - } else { - frm.toggle_display(["auth_method"], true); - } + frappe.call({ + method: "frappe.integrations.doctype.connected_app.connected_app.check_active_token", + args: { + connected_app: frm.doc.connected_app, + connected_user: frm.doc.connected_user, + }, + callback: r => { + if (!r.message) { + oauth_access(frm); + } + }, + }); }, show_gmail_message_for_less_secure_apps: function (frm) { @@ -197,13 +203,22 @@ frappe.ui.form.on("Email Account", { }, show_oauth_authorization_message(frm) { - if (frm.doc.auth_method === "OAuth" && !frm.doc.refresh_token) { - let msg = __( - 'OAuth has been enabled but not authorised. Please use "Authorise API Access" button to do the same.' - ); - frm.dashboard.clear_headline(); - frm.dashboard.set_headline_alert(msg, "yellow"); - } + frappe.call({ + method: "frappe.integrations.doctype.connected_app.connected_app.check_active_token", + args: { + connected_app: frm.doc.connected_app, + connected_user: frm.doc.connected_user, + }, + callback: r => { + if (frm.doc.auth_method === "OAuth" && !r.message) { + let msg = __( + 'OAuth has been enabled but not authorised. Please use "Authorise API Access" button to do the same.' + ); + frm.dashboard.clear_headline(); + frm.dashboard.set_headline_alert(msg, "yellow"); + } + }, + }); }, authorize_api_access: function (frm) { diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index da88ac680c..38345ea618 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -20,8 +20,8 @@ "awaiting_password", "ascii_encode_password", "column_break_10", - "refresh_token", - "access_token", + "connected_app", + "connected_user", "login_id_is_different", "login_id", "mailbox_settings", @@ -577,25 +577,11 @@ "label": "IMAP Details" }, { - "depends_on": "eval: doc.service === \"GMail\" && doc.auth_method === \"OAuth\" && !doc.__islocal && !doc.__unsaved", + "depends_on": "eval: doc.auth_method === \"OAuth\" && !doc.__islocal && !doc.__unsaved", "fieldname": "authorize_api_access", "fieldtype": "Button", "label": "Authorize API Access" }, - { - "fieldname": "refresh_token", - "fieldtype": "Small Text", - "hidden": 1, - "label": "Refresh Token", - "read_only": 1 - }, - { - "fieldname": "access_token", - "fieldtype": "Small Text", - "hidden": 1, - "label": "Access Token", - "read_only": 1 - }, { "default": "Basic", "fieldname": "auth_method", @@ -610,12 +596,28 @@ "fieldname": "use_starttls", "fieldtype": "Check", "label": "Use STARTTLS" + }, + { + "depends_on": "eval: doc.auth_method === \"OAuth\"", + "fieldname": "connected_app", + "fieldtype": "Link", + "label": "Connected App", + "mandatory_depends_on": "eval: doc.auth_method === \"OAuth\"", + "options": "Connected App" + }, + { + "depends_on": "eval: doc.auth_method === \"OAuth\"", + "fieldname": "connected_user", + "fieldtype": "Link", + "label": "Connected User", + "mandatory_depends_on": "eval: doc.auth_method === \"OAuth\"", + "options": "User" } ], "icon": "fa fa-inbox", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-08-23 00:31:05.305462", + "modified": "2022-11-03 20:26:52.876668", "modified_by": "Administrator", "module": "Email", "name": "Email Account", diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 1db45604e1..30fad87565 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -11,6 +11,7 @@ from poplib import error_proto import frappe from frappe import _, are_emails_muted, safe_encode +from frappe.integrations.doctype.connected_app.connected_app import check_active_token from frappe.desk.form import assign_to from frappe.email.doctype.email_domain.email_domain import EMAIL_DOMAIN_FIELDS from frappe.email.receive import EmailServer, InboundMail, SentEmailInInboxError @@ -85,21 +86,13 @@ class EmailAccount(Document): use_oauth = self.auth_method == "OAuth" self.use_starttls = cint(self.use_imap and self.use_starttls and not self.use_ssl) - if getattr(self, "service", "") != "GMail" and use_oauth: - self.auth_method = "Basic" - use_oauth = False - if use_oauth: # no need for awaiting password for oauth self.awaiting_password = 0 self.password = None - elif self.refresh_token: - # clear access & refresh token - self.refresh_token = self.access_token = None - if not frappe.local.flags.in_install and not self.awaiting_password: - if self.refresh_token or self.password or self.smtp_server in ("127.0.0.1", "localhost"): + if self.auth_method == "OAuth" or self.password or self.smtp_server in ("127.0.0.1", "localhost"): if self.enable_incoming: self.get_incoming_server() self.no_failed = 0 @@ -188,6 +181,7 @@ class EmailAccount(Document): if frappe.cache().get_value("workers:no-internet") == True: return None + oauth_token = self.get_oauth_token() args = frappe._dict( { "email_account_name": self.email_account_name, @@ -202,8 +196,8 @@ class EmailAccount(Document): "incoming_port": get_port(self), "initial_sync_count": self.initial_sync_count or 100, "use_oauth": self.auth_method == "OAuth", - "refresh_token": decrypt(self.refresh_token) if self.refresh_token else None, - "access_token": decrypt(self.access_token) if self.access_token else None, + "refresh_token": oauth_token.get_password("refresh_token") if oauth_token else None, + "access_token": oauth_token.get_password("refresh_token") if oauth_token else None, } ) @@ -392,8 +386,6 @@ class EmailAccount(Document): }, "name": {"conf_names": ("email_sender_name",), "default": "Frappe"}, "auth_method": {"conf_names": ("auth_method"), "default": "Basic"}, - "access_token": {"conf_names": ("mail_access_token")}, - "refresh_token": {"conf_names": ("mail_refresh_token")}, "from_site_config": {"default": True}, } @@ -401,15 +393,13 @@ class EmailAccount(Document): for doc_field_name, d in field_to_conf_name_map.items(): conf_names, default = d.get("conf_names") or [], d.get("default") value = [frappe.conf.get(k) for k in conf_names if frappe.conf.get(k)] - - if doc_field_name in ("refresh_token", "access_token"): - account_details[doc_field_name] = value and encrypt(value[0]) - else: - account_details[doc_field_name] = (value and value[0]) or default + account_details[doc_field_name] = (value and value[0]) or default return account_details def sendmail_config(self): + oauth_token = self.get_oauth_token() + return { "email_account": self.name, "server": self.smtp_server, @@ -420,8 +410,8 @@ class EmailAccount(Document): "use_tls": cint(self.use_tls), "service": getattr(self, "service", ""), "use_oauth": self.auth_method == "OAuth", - "refresh_token": decrypt(self.refresh_token) if self.refresh_token else None, - "access_token": decrypt(self.access_token) if self.access_token else None, + "refresh_token": oauth_token.get_password("refresh_token") if oauth_token else None, + "access_token": oauth_token.get_password("refresh_token") if oauth_token else None, } def get_smtp_server(self): @@ -681,6 +671,13 @@ class EmailAccount(Document): except Exception: self.log_error("Unable to add to Sent folder") + def get_oauth_token(self): + token = None + if self.auth_method == "OAuth": + connected_app = frappe.get_doc("Connected App", self.connected_app) + token = connected_app.get_active_token(self.connected_user) + + return token @frappe.whitelist() def get_append_to( @@ -786,15 +783,20 @@ def pull(now=False): doctype = frappe.qb.DocType("Email Account") email_accounts = ( frappe.qb.from_(doctype) - .select(doctype.name) + .select(doctype.name, doctype.auth_method) .where(doctype.enable_incoming == 1) .where( - (doctype.awaiting_password == 0) - | ((doctype.auth_method == "OAuth") & (doctype.refresh_token.isnotnull())) + (doctype.awaiting_password == 0) | (doctype.auth_method == "OAuth") ) .run(as_dict=1) ) for email_account in email_accounts: + if email_account.auth_method == "OAuth" and not check_active_token( + connected_app=doctype.connected_app, + connected_user=doctype.connected_user, + ): + continue + if now: pull_from_email_account(email_account.name) diff --git a/frappe/email/oauth.py b/frappe/email/oauth.py index f5b60a9f3d..af86bc47d2 100644 --- a/frappe/email/oauth.py +++ b/frappe/email/oauth.py @@ -53,7 +53,7 @@ class Oauth: return f"user={self.email}\1auth=Bearer {self._access_token}\1\1" def connect(self, _retry: int = 0) -> None: - """Connection method with retry on exception for Oauth""" + """Connection method with retry on exception for connection errors""" try: if isinstance(self._conn, POP3): res = self._connect_pop() @@ -69,18 +69,14 @@ class Oauth: self._connect_smtp() except Exception as e: - # maybe the access token expired - refreshing - access_token = self._refresh_access_token() - - if not access_token or _retry > 0: + if _retry > 0: frappe.log_error( - "OAuth Error - Authentication Failed", str(e), "Email Account", self.email_account + "SMTP Connection Error - Authentication Failed", str(e), "Email Account", self.email_account ) # raising a bare exception here as we have a lot of exception handling present # where the connect method is called from - hence just logging and raising. raise - self._access_token = access_token self.connect(_retry + 1) def _connect_pop(self) -> bytes: @@ -98,70 +94,3 @@ class Oauth: def _connect_smtp(self) -> None: self._conn.auth(self._mechanism, lambda x: self._auth_string, initial_response_ok=False) - - def _refresh_access_token(self) -> str: - """Refreshes access token via calling `refresh_access_token` method of oauth service object""" - service_obj = self._get_service_object() - access_token = service_obj.refresh_access_token(self._refresh_token).get("access_token") - - if access_token: - # set the new access token in db - frappe.db.set_value( - "Email Account", - self.email_account, - "access_token", - encrypt(access_token), - update_modified=False, - ) - - return access_token - - def _get_service_object(self): - """Get Oauth service object""" - - return { - "GMail": GoogleOAuth("mail", validate=False), - }[self.service] - - -@frappe.whitelist(methods=["POST"]) -def oauth_access(email_account: str, service: str): - """Used as a default endpoint/caller for all oauth services. - Returns authorization url for redirection""" - - if not service: - frappe.throw(frappe._("No Service is selected. Please select one and try again!")) - - if service == "GMail": - return authorize_google_access(email_account) - - raise NotImplementedError(f"Service {service} currently doesn't have oauth implementation.") - - -def authorize_google_access(email_account: str, code: str = None): - """Facilitates google oauth for email. - This is invoked 2 times - first time when user clicks `Authorize API Access` for getting the authorization url - and second time for setting the refresh and access token in db when google redirects back with oauth code.""" - - doctype = "Email Account" - oauth_obj = GoogleOAuth("mail") - - if not code: - return oauth_obj.get_authentication_url( - { - "redirect": f"/app/Form/{quote(doctype)}/{quote(email_account)}", - "success_query_param": "successful_authorization=1", - "email_account": email_account, - }, - ) - - res = oauth_obj.authorize(code) - frappe.db.set_value( - doctype, - email_account, - { - "refresh_token": encrypt(res.get("refresh_token")), - "access_token": encrypt(res.get("access_token")), - }, - update_modified=False, - ) diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index 308d1ca84a..46894132e8 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -57,7 +57,7 @@ class ConnectedApp(Document): def initiate_web_application_flow(self, user=None, success_uri=None): """Return an authorization URL for the user. Save state in Token Cache.""" user = user or frappe.session.user - oauth = self.get_oauth2_session(init=True) + oauth = self.get_oauth2_session(user, init=True) query_params = self.get_query_params() authorization_url, state = oauth.authorization_url(self.authorization_uri, **query_params) token_cache = self.get_token_cache(user) @@ -102,6 +102,21 @@ class ConnectedApp(Document): def get_query_params(self): return {param.key: param.value for param in self.query_parameters} + def get_active_token(self, user=None): + user = user or frappe.session.user + token_cache = self.get_token_cache(user) + if token_cache and not token_cache.is_expired(): + return token_cache + elif token_cache and token_cache.get_expires_in(): + oauth_session = self.get_oauth2_session(user) + token = oauth_session.refresh_token( + body=f"redirect_uri={self.redirect_uri}", + token_url=self.token_uri, + refresh_token=token_cache.get_password("refresh_token"), + ) + token_cache.update_data(token) + return token_cache + @frappe.whitelist(allow_guest=True) def callback(code=None, state=None): @@ -142,3 +157,15 @@ def callback(code=None, state=None): frappe.local.response["type"] = "redirect" frappe.local.response["location"] = token_cache.get("success_uri") or connected_app.get_url() + + +@frappe.whitelist() +def check_active_token(connected_app, connected_user=None): + is_token_active = False + app = frappe.get_doc("Connected App", connected_app) + token = app.get_active_token(connected_user) + + if token: + is_token_active = True + + return is_token_active diff --git a/frappe/integrations/doctype/token_cache/token_cache.py b/frappe/integrations/doctype/token_cache/token_cache.py index 25f07a16ba..b01974e5ab 100644 --- a/frappe/integrations/doctype/token_cache/token_cache.py +++ b/frappe/integrations/doctype/token_cache/token_cache.py @@ -3,6 +3,8 @@ from datetime import datetime, timedelta +import pytz + import frappe from frappe import _ from frappe.model.document import Document @@ -50,8 +52,11 @@ class TokenCache(Document): return self def get_expires_in(self): - expiry_time = frappe.utils.get_datetime(self.modified) + timedelta(self.expires_in) - return (datetime.now() - expiry_time).total_seconds() + now_utc = datetime.utcnow().replace(tzinfo=pytz.utc) + expiry_time = frappe.utils.get_datetime(self.modified) + timedelta(seconds=self.expires_in) + expiry_local = expiry_time.replace(tzinfo=pytz.timezone(frappe.utils.get_time_zone())) + expiry_utc = expiry_local.astimezone(pytz.utc) + return (expiry_utc - now_utc).total_seconds() def is_expired(self): return self.get_expires_in() < 0 From a4e5df674b2c938b187cd2f07172bfc71ccb6e2a Mon Sep 17 00:00:00 2001 From: Revant Nandgaonkar Date: Sun, 6 Nov 2022 06:11:24 +0000 Subject: [PATCH 003/407] fix(email): oauth_access call and info message --- .../doctype/email_account/email_account.js | 86 +++++++++---------- 1 file changed, 42 insertions(+), 44 deletions(-) diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index 92b7deece3..ba6ebd4de7 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -67,22 +67,16 @@ frappe.email_defaults_pop = { }; function oauth_access(frm) { - return frappe.call({ - method: "frappe.client.get", - args: { - doctype: "Connected App", - name: frm.doc.connected_app, - }, - callback: app => { - return frappe.call({ - method: "initiate_web_application_flow", - doc: app.message, - callback: function (r) { - window.open(r.message, "_self"); - }, - }); - }, - }); + frappe.model.with_doc("Connected App", frm.doc.connected_app, () => { + const connected_app = frappe.get_doc("Connected App", frm.doc.connected_app); + return frappe.call({ + doc: connected_app, + method: "initiate_web_application_flow", + callback: function(r) { + window.open(r.message, "_self"); + } + }); + }) } function set_default_max_attachment_size(frm, field) { @@ -176,18 +170,20 @@ frappe.ui.form.on("Email Account", { }, after_save(frm) { - frappe.call({ - method: "frappe.integrations.doctype.connected_app.connected_app.check_active_token", - args: { - connected_app: frm.doc.connected_app, - connected_user: frm.doc.connected_user, - }, - callback: r => { - if (!r.message) { - oauth_access(frm); - } - }, - }); + if (frm.doc.auth_method === "OAuth") { + frappe.call({ + method: "frappe.integrations.doctype.connected_app.connected_app.check_active_token", + args: { + connected_app: frm.doc.connected_app, + connected_user: frm.doc.connected_user, + }, + callback: r => { + if (!r.message) { + oauth_access(frm); + } + }, + }); + } }, show_gmail_message_for_less_secure_apps: function (frm) { @@ -203,22 +199,24 @@ frappe.ui.form.on("Email Account", { }, show_oauth_authorization_message(frm) { - frappe.call({ - method: "frappe.integrations.doctype.connected_app.connected_app.check_active_token", - args: { - connected_app: frm.doc.connected_app, - connected_user: frm.doc.connected_user, - }, - callback: r => { - if (frm.doc.auth_method === "OAuth" && !r.message) { - let msg = __( - 'OAuth has been enabled but not authorised. Please use "Authorise API Access" button to do the same.' - ); - frm.dashboard.clear_headline(); - frm.dashboard.set_headline_alert(msg, "yellow"); - } - }, - }); + if (frm.doc.auth_method === "OAuth") { + frappe.call({ + method: "frappe.integrations.doctype.connected_app.connected_app.check_active_token", + args: { + connected_app: frm.doc.connected_app, + connected_user: frm.doc.connected_user, + }, + callback: r => { + if (!r.message) { + let msg = __( + 'OAuth has been enabled but not authorised. Please use "Authorise API Access" button to do the same.' + ); + frm.dashboard.clear_headline(); + frm.dashboard.set_headline_alert(msg, "yellow"); + } + }, + }); + } }, authorize_api_access: function (frm) { From f0a17d7adb68a57c16322eb18f47f44469d63054 Mon Sep 17 00:00:00 2001 From: phot0n Date: Wed, 28 Dec 2022 14:48:16 +0530 Subject: [PATCH 004/407] fix: make connected app work with email account * removed (now) unnecessary things from oauth class * simplified get_active_token in connected_app * removed gmail banner from email account docs --- .../doctype/email_account/email_account.js | 62 +++---------------- .../doctype/email_account/email_account.json | 3 +- .../doctype/email_account/email_account.py | 26 +++----- frappe/email/oauth.py | 41 ++++-------- frappe/email/receive.py | 4 -- frappe/email/smtp.py | 8 +-- .../doctype/connected_app/connected_app.py | 9 ++- 7 files changed, 33 insertions(+), 120 deletions(-) diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index ba6ebd4de7..67e2ffc863 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -72,20 +72,20 @@ function oauth_access(frm) { return frappe.call({ doc: connected_app, method: "initiate_web_application_flow", - callback: function(r) { + callback: function (r) { window.open(r.message, "_self"); - } + }, }); - }) + }); } -function set_default_max_attachment_size(frm, field) { - if (frm.doc.__islocal && !frm.doc[field]) { +function set_default_max_attachment_size(frm) { + if (frm.doc.__islocal && !frm.doc["attachment_limit"]) { frappe.call({ method: "frappe.core.api.file.get_max_file_size", callback: function (r) { if (!r.exc) { - frm.set_value(field, Number(r.message) / (1024 * 1024)); + frm.set_value("attachment_limit", Number(r.message) / (1024 * 1024)); } }, }); @@ -102,7 +102,6 @@ frappe.ui.form.on("Email Account", { frm.set_value(key, value); }); } - frm.events.show_gmail_message_for_less_secure_apps(frm); }, use_imap: function (frm) { @@ -130,12 +129,6 @@ frappe.ui.form.on("Email Account", { }, onload: function (frm) { - if (frappe.utils.get_query_params().successful_authorization === "1") { - frappe.show_alert(__("Successfully Authorized")); - // FIXME: find better alternative - window.history.replaceState(null, "", window.location.pathname); - } - frm.set_df_property("append_to", "only_select", true); frm.set_query( "append_to", @@ -150,23 +143,17 @@ frappe.ui.form.on("Email Account", { frm.add_child("imap_folder", { folder_name: "INBOX" }); frm.refresh_field("imap_folder"); } - set_default_max_attachment_size(frm, "attachment_limit"); + set_default_max_attachment_size(frm); }, refresh: function (frm) { frm.events.enable_incoming(frm); frm.events.notify_if_unreplied(frm); - frm.events.show_gmail_message_for_less_secure_apps(frm); - frm.events.show_oauth_authorization_message(frm); if (frappe.route_flags.delete_user_from_locals && frappe.route_flags.linked_user) { delete frappe.route_flags.delete_user_from_locals; delete locals["User"][frappe.route_flags.linked_user]; } - - if (frm.doc.connected_app && !frm.doc.connected_user) { - frm.set_value("connected_user", frappe.session.user); - } }, after_save(frm) { @@ -177,7 +164,7 @@ frappe.ui.form.on("Email Account", { connected_app: frm.doc.connected_app, connected_user: frm.doc.connected_user, }, - callback: r => { + callback: (r) => { if (!r.message) { oauth_access(frm); } @@ -186,39 +173,6 @@ frappe.ui.form.on("Email Account", { } }, - show_gmail_message_for_less_secure_apps: function (frm) { - frm.dashboard.clear_headline(); - let msg = __( - "GMail will only work if you enable 2-step authentication and use app-specific password OR use OAuth." - ); - let cta = __("Read the step by step guide here."); - msg += ` ${cta}`; - if (frm.doc.service === "GMail") { - frm.dashboard.set_headline_alert(msg); - } - }, - - show_oauth_authorization_message(frm) { - if (frm.doc.auth_method === "OAuth") { - frappe.call({ - method: "frappe.integrations.doctype.connected_app.connected_app.check_active_token", - args: { - connected_app: frm.doc.connected_app, - connected_user: frm.doc.connected_user, - }, - callback: r => { - if (!r.message) { - let msg = __( - 'OAuth has been enabled but not authorised. Please use "Authorise API Access" button to do the same.' - ); - frm.dashboard.clear_headline(); - frm.dashboard.set_headline_alert(msg, "yellow"); - } - }, - }); - } - }, - authorize_api_access: function (frm) { oauth_access(frm); }, diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index 38345ea618..f9e4f95ff0 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -203,7 +203,6 @@ "label": "Use SSL" }, { - "default": "1", "depends_on": "eval:!doc.domain && doc.enable_incoming", "description": "Ignore attachments over this size", "fetch_from": "domain.attachment_limit", @@ -617,7 +616,7 @@ "icon": "fa fa-inbox", "index_web_pages_for_search": 1, "links": [], - "modified": "2022-11-03 20:26:52.876668", + "modified": "2022-12-28 14:56:18.754804", "modified_by": "Administrator", "module": "Email", "name": "Email Account", diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 30fad87565..3ad2f67c0b 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -11,18 +11,17 @@ from poplib import error_proto import frappe from frappe import _, are_emails_muted, safe_encode -from frappe.integrations.doctype.connected_app.connected_app import check_active_token from frappe.desk.form import assign_to from frappe.email.doctype.email_domain.email_domain import EMAIL_DOMAIN_FIELDS from frappe.email.receive import EmailServer, InboundMail, SentEmailInInboxError from frappe.email.smtp import SMTPServer from frappe.email.utils import get_port +from frappe.integrations.doctype.connected_app.connected_app import check_active_token from frappe.model.document import Document from frappe.utils import cint, comma_or, cstr, parse_addr, validate_email_address from frappe.utils.background_jobs import enqueue, get_jobs from frappe.utils.error import raise_error_on_no_output from frappe.utils.jinja import render_template -from frappe.utils.password import decrypt, encrypt from frappe.utils.user import get_system_managers @@ -92,7 +91,7 @@ class EmailAccount(Document): self.password = None if not frappe.local.flags.in_install and not self.awaiting_password: - if self.auth_method == "OAuth" or self.password or self.smtp_server in ("127.0.0.1", "localhost"): + if use_oauth or self.password or self.smtp_server in ("127.0.0.1", "localhost"): if self.enable_incoming: self.get_incoming_server() self.no_failed = 0 @@ -190,14 +189,12 @@ class EmailAccount(Document): "use_ssl": self.use_ssl, "use_starttls": self.use_starttls, "username": getattr(self, "login_id", None) or self.email_id, - "service": getattr(self, "service", ""), "use_imap": self.use_imap, "email_sync_rule": email_sync_rule, "incoming_port": get_port(self), "initial_sync_count": self.initial_sync_count or 100, "use_oauth": self.auth_method == "OAuth", - "refresh_token": oauth_token.get_password("refresh_token") if oauth_token else None, - "access_token": oauth_token.get_password("refresh_token") if oauth_token else None, + "access_token": oauth_token.get_password("access_token") if oauth_token else None, } ) @@ -408,10 +405,8 @@ class EmailAccount(Document): "password": self._password, "use_ssl": cint(self.use_ssl_for_outgoing), "use_tls": cint(self.use_tls), - "service": getattr(self, "service", ""), "use_oauth": self.auth_method == "OAuth", - "refresh_token": oauth_token.get_password("refresh_token") if oauth_token else None, - "access_token": oauth_token.get_password("refresh_token") if oauth_token else None, + "access_token": oauth_token.get_password("access_token") if oauth_token else None, } def get_smtp_server(self): @@ -679,6 +674,7 @@ class EmailAccount(Document): return token + @frappe.whitelist() def get_append_to( doctype=None, txt=None, searchfield=None, start=None, page_len=None, filters=None @@ -783,20 +779,12 @@ def pull(now=False): doctype = frappe.qb.DocType("Email Account") email_accounts = ( frappe.qb.from_(doctype) - .select(doctype.name, doctype.auth_method) + .select(doctype.name) .where(doctype.enable_incoming == 1) - .where( - (doctype.awaiting_password == 0) | (doctype.auth_method == "OAuth") - ) + .where((doctype.awaiting_password == 0) | (doctype.auth_method == "OAuth")) .run(as_dict=1) ) for email_account in email_accounts: - if email_account.auth_method == "OAuth" and not check_active_token( - connected_app=doctype.connected_app, - connected_user=doctype.connected_user, - ): - continue - if now: pull_from_email_account(email_account.name) diff --git a/frappe/email/oauth.py b/frappe/email/oauth.py index af86bc47d2..c69ab2211e 100644 --- a/frappe/email/oauth.py +++ b/frappe/email/oauth.py @@ -2,11 +2,8 @@ import base64 from imaplib import IMAP4 from poplib import POP3 from smtplib import SMTP -from urllib.parse import quote import frappe -from frappe.integrations.google_oauth import GoogleOAuth -from frappe.utils.password import encrypt class OAuthenticationError(Exception): @@ -20,30 +17,21 @@ class Oauth: email_account: str, email: str, access_token: str, - refresh_token: str, - service: str, mechanism: str = "XOAUTH2", ) -> None: self.email_account = email_account self.email = email - self.service = service self._mechanism = mechanism self._conn = conn self._access_token = access_token - self._refresh_token = refresh_token self._validate() def _validate(self) -> None: - if self.service != "GMail": - raise NotImplementedError( - f"Service {self.service} currently doesn't have oauth implementation." - ) - - if not self._refresh_token: + if not self._access_token: frappe.throw( - frappe._("Please Authorize OAuth."), + frappe._("Please Authorize OAuth for Email Account {}").format(self.email_account), OAuthenticationError, frappe._("OAuth Error"), ) @@ -52,14 +40,11 @@ class Oauth: def _auth_string(self) -> str: return f"user={self.email}\1auth=Bearer {self._access_token}\1\1" - def connect(self, _retry: int = 0) -> None: + def connect(self) -> None: """Connection method with retry on exception for connection errors""" try: if isinstance(self._conn, POP3): - res = self._connect_pop() - - if not res.startswith(b"+OK"): - raise + self._connect_pop() elif isinstance(self._conn, IMAP4): self._connect_imap() @@ -69,15 +54,12 @@ class Oauth: self._connect_smtp() except Exception as e: - if _retry > 0: - frappe.log_error( - "SMTP Connection Error - Authentication Failed", str(e), "Email Account", self.email_account - ) - # raising a bare exception here as we have a lot of exception handling present - # where the connect method is called from - hence just logging and raising. - raise - - self.connect(_retry + 1) + frappe.log_error( + "Email Connection Error - Authentication Failed", str(e), "Email Account", self.email_account + ) + # raising a bare exception here as we have a lot of exception handling present + # where the connect method is called from - hence just logging and raising. + raise def _connect_pop(self) -> bytes: # poplib doesn't have AUTH command implementation @@ -87,7 +69,8 @@ class Oauth: ) ) - return res + if not res.startswith(b"+OK"): + raise def _connect_imap(self) -> None: self._conn.authenticate(self._mechanism, lambda x: self._auth_string) diff --git a/frappe/email/receive.py b/frappe/email/receive.py index 7028dc1f11..c635bdd98a 100644 --- a/frappe/email/receive.py +++ b/frappe/email/receive.py @@ -109,8 +109,6 @@ class EmailServer: self.settings.email_account, self.settings.username, self.settings.access_token, - self.settings.refresh_token, - self.settings.service, ).connect() else: @@ -142,8 +140,6 @@ class EmailServer: self.settings.email_account, self.settings.username, self.settings.access_token, - self.settings.refresh_token, - self.settings.service, ).connect() else: diff --git a/frappe/email/smtp.py b/frappe/email/smtp.py index 10eb2f7681..028b21b0ae 100644 --- a/frappe/email/smtp.py +++ b/frappe/email/smtp.py @@ -54,9 +54,7 @@ class SMTPServer: use_tls=None, use_ssl=None, use_oauth=0, - refresh_token=None, access_token=None, - service=None, ): self.login = login self.email_account = email_account @@ -66,9 +64,7 @@ class SMTPServer: self.use_tls = use_tls self.use_ssl = use_ssl self.use_oauth = use_oauth - self.refresh_token = refresh_token self.access_token = access_token - self.service = service self._session = None if not self.server: @@ -112,9 +108,7 @@ class SMTPServer: self.secure_session(_session) if self.use_oauth: - Oauth( - _session, self.email_account, self.login, self.access_token, self.refresh_token, self.service - ).connect() + Oauth(_session, self.email_account, self.login, self.access_token).connect() elif self.password: res = _session.login(str(self.login or ""), str(self.password or "")) diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index 46894132e8..e7da138fbc 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -105,9 +105,7 @@ class ConnectedApp(Document): def get_active_token(self, user=None): user = user or frappe.session.user token_cache = self.get_token_cache(user) - if token_cache and not token_cache.is_expired(): - return token_cache - elif token_cache and token_cache.get_expires_in(): + if token_cache and token_cache.is_expired(): oauth_session = self.get_oauth2_session(user) token = oauth_session.refresh_token( body=f"redirect_uri={self.redirect_uri}", @@ -115,7 +113,8 @@ class ConnectedApp(Document): refresh_token=token_cache.get_password("refresh_token"), ) token_cache.update_data(token) - return token_cache + + return token_cache @frappe.whitelist(allow_guest=True) @@ -151,7 +150,7 @@ def callback(code=None, state=None): code=code, client_secret=connected_app.get_password("client_secret"), include_client_id=True, - **query_params + **query_params, ) token_cache.update_data(token) From ca8842861a0bc34cda7103e2443283a8ae013766 Mon Sep 17 00:00:00 2001 From: phot0n Date: Thu, 29 Dec 2022 17:03:16 +0530 Subject: [PATCH 005/407] refactor(minor): simplify check_active_token --- .../integrations/doctype/connected_app/connected_app.py | 8 +------- frappe/translations/ru.csv | 1 + frappe/website/doctype/blog_post/templates/blog_post.html | 2 +- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index e7da138fbc..cd219263e9 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -160,11 +160,5 @@ def callback(code=None, state=None): @frappe.whitelist() def check_active_token(connected_app, connected_user=None): - is_token_active = False app = frappe.get_doc("Connected App", connected_app) - token = app.get_active_token(connected_user) - - if token: - is_token_active = True - - return is_token_active + return bool(app.get_active_token(connected_user)) diff --git a/frappe/translations/ru.csv b/frappe/translations/ru.csv index f83bf411ff..1c99690ff3 100644 --- a/frappe/translations/ru.csv +++ b/frappe/translations/ru.csv @@ -6,6 +6,7 @@ Account,Аккаунт, Accounts Manager,Диспетчер учетных записей, Accounts User,Пользователь Учетных записей, Action,Действие, +min read, Действие, Actions,Действия, Active,Активен, Add,Добавить, diff --git a/frappe/website/doctype/blog_post/templates/blog_post.html b/frappe/website/doctype/blog_post/templates/blog_post.html index 67236fb26d..708b896357 100644 --- a/frappe/website/doctype/blog_post/templates/blog_post.html +++ b/frappe/website/doctype/blog_post/templates/blog_post.html @@ -22,7 +22,7 @@ {%- if read_time -%}  · - {{ read_time }} min read + {{ read_time }} {{ _("min read") }} {%- endif -%} From b78f86a30790d95cc1b90aa3743395db220c9df2 Mon Sep 17 00:00:00 2001 From: phot0n Date: Thu, 29 Dec 2022 17:14:28 +0530 Subject: [PATCH 006/407] fix: redirect back to email account after successful oauth --- frappe/email/doctype/email_account/email_account.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index 67e2ffc863..31da072692 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -72,6 +72,9 @@ function oauth_access(frm) { return frappe.call({ doc: connected_app, method: "initiate_web_application_flow", + args: { + success_uri: window.location.pathname, + }, callback: function (r) { window.open(r.message, "_self"); }, From 231d90cb40eda39543a55ea6a5fd588bdc5fa514 Mon Sep 17 00:00:00 2001 From: phot0n Date: Thu, 29 Dec 2022 17:26:08 +0530 Subject: [PATCH 007/407] chore: handle get requests via frappe.whitelist decorator --- frappe/email/doctype/email_account/email_account.py | 1 - frappe/email/oauth.py | 2 +- frappe/integrations/doctype/connected_app/connected_app.py | 4 +--- frappe/translations/ru.csv | 1 - frappe/website/doctype/blog_post/templates/blog_post.html | 2 +- 5 files changed, 3 insertions(+), 7 deletions(-) diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 3ad2f67c0b..a0fc8f162e 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -16,7 +16,6 @@ from frappe.email.doctype.email_domain.email_domain import EMAIL_DOMAIN_FIELDS from frappe.email.receive import EmailServer, InboundMail, SentEmailInInboxError from frappe.email.smtp import SMTPServer from frappe.email.utils import get_port -from frappe.integrations.doctype.connected_app.connected_app import check_active_token from frappe.model.document import Document from frappe.utils import cint, comma_or, cstr, parse_addr, validate_email_address from frappe.utils.background_jobs import enqueue, get_jobs diff --git a/frappe/email/oauth.py b/frappe/email/oauth.py index c69ab2211e..ad951d770e 100644 --- a/frappe/email/oauth.py +++ b/frappe/email/oauth.py @@ -61,7 +61,7 @@ class Oauth: # where the connect method is called from - hence just logging and raising. raise - def _connect_pop(self) -> bytes: + def _connect_pop(self) -> None: # poplib doesn't have AUTH command implementation res = self._conn._shortcmd( "AUTH {} {}".format( diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index cd219263e9..e8193c89f2 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -117,7 +117,7 @@ class ConnectedApp(Document): return token_cache -@frappe.whitelist(allow_guest=True) +@frappe.whitelist(methods=["GET"], allow_guest=True) def callback(code=None, state=None): """Handle client's code. @@ -125,8 +125,6 @@ def callback(code=None, state=None): transmit a code that can be used by the local server to obtain an access token. """ - if frappe.request.method != "GET": - frappe.throw(_("Invalid request method: {}").format(frappe.request.method)) if frappe.session.user == "Guest": frappe.local.response["type"] = "redirect" diff --git a/frappe/translations/ru.csv b/frappe/translations/ru.csv index 1c99690ff3..f83bf411ff 100644 --- a/frappe/translations/ru.csv +++ b/frappe/translations/ru.csv @@ -6,7 +6,6 @@ Account,Аккаунт, Accounts Manager,Диспетчер учетных записей, Accounts User,Пользователь Учетных записей, Action,Действие, -min read, Действие, Actions,Действия, Active,Активен, Add,Добавить, diff --git a/frappe/website/doctype/blog_post/templates/blog_post.html b/frappe/website/doctype/blog_post/templates/blog_post.html index 708b896357..67236fb26d 100644 --- a/frappe/website/doctype/blog_post/templates/blog_post.html +++ b/frappe/website/doctype/blog_post/templates/blog_post.html @@ -22,7 +22,7 @@ {%- if read_time -%}  · - {{ read_time }} {{ _("min read") }} + {{ read_time }} min read {%- endif -%} From 847206222f9033ae3e1212a94b76c1df94319d48 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 31 Dec 2022 22:17:20 +0530 Subject: [PATCH 008/407] fix: delete option --- frappe/database/database.py | 2 +- frappe/database/query.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index acbd28c9d7..9bb4bfc8e6 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -1160,7 +1160,7 @@ class Database: Doctype name can be passed directly, it will be pre-pended with `tab`. """ filters = filters or kwargs.get("conditions") - query = frappe.qb.get_query(table=doctype, filters=filters).delete() + query = frappe.qb.get_query(table=doctype, filters=filters, delete=True) if "debug" not in kwargs: kwargs["debug"] = debug return query.run(**kwargs) diff --git a/frappe/database/query.py b/frappe/database/query.py index 88de7f7088..7119819a40 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -245,6 +245,7 @@ class Engine: for_update: bool = False, update: bool = False, into: bool = False, + delete: bool = False, ) -> MySQLQueryBuilder | PostgreSQLQueryBuilder: # Clean up state before each query self.is_mariadb = frappe.db.db_type == "mariadb" @@ -259,6 +260,8 @@ class Engine: self.query = frappe.qb.update(self.table) elif into: self.query = frappe.qb.into(self.table) + elif delete: + self.query = frappe.qb.from_(self.table).delete() else: self.query = frappe.qb.from_(self.table) From e272adb0b13a4e0642af1e9af6e758299eee7c7d Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 31 Dec 2022 22:17:39 +0530 Subject: [PATCH 009/407] fix: use table.field instead Field('field') --- frappe/database/query.py | 44 ++++++++-------------------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index 7119819a40..b9e60043a0 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -130,26 +130,6 @@ def func_timespan(key: Field, value: str) -> frappe.qb: return func_between(key, get_timespan_date_range(value)) -def change_orderby(order: str): - """Convert orderby to standart Order object - - Args: - order (str): Field, order - - Returns: - tuple: field, order - """ - order = order.split() - - try: - if order[1].lower() == "asc": - return order[0], Order.asc - except IndexError: - pass - - return order[0], Order.desc - - def literal_eval_(literal): try: return literal_eval(literal) @@ -164,14 +144,6 @@ def has_function(field): return True -def table_from_string(table: str) -> "DocType": - if frappe.db.db_type == "postgres": - table_name = table.split('"', maxsplit=1)[1].split(".")[0][3:].replace('"', "") - else: - table_name = table.split("`", maxsplit=1)[1].split(".")[0][3:].replace("`", "") - return frappe.qb.DocType(table_name=table_name) - - def get_nested_set_hierarchy_result(doctype: str, name: str, hierarchy: str): table = frappe.qb.DocType(doctype) try: @@ -182,20 +154,20 @@ def get_nested_set_hierarchy_result(doctype: str, name: str, hierarchy: str): if hierarchy in ("descendants of", "not descendants of"): result = ( frappe.qb.from_(table) - .select(Field("name")) - .where(Field("lft") > lft) - .where(Field("rgt") < rgt) - .orderby(Field("lft"), order=Order.asc) + .select(table.name) + .where(table.lft > lft) + .where(table.rgt < rgt) + .orderby(table.lft, order=Order.asc) .run() ) else: # Get ancestor elements of a DocType with a tree structure result = ( frappe.qb.from_(table) - .select(Field("name")) - .where(Field("lft") < lft) - .where(Field("rgt") > rgt) - .orderby(Field("lft"), order=Order.desc) + .select(table.name) + .where(table.lft < lft) + .where(table.rgt > rgt) + .orderby(table.lft, order=Order.desc) .run() ) return result From 99160cbe1703d047c36766e07a6e6fa82064b476 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 31 Dec 2022 22:17:54 +0530 Subject: [PATCH 010/407] fix: refactor usage --- frappe/desk/doctype/number_card/number_card.py | 2 +- frappe/desk/listview.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py index 30ec99644a..d940448cb1 100644 --- a/frappe/desk/doctype/number_card/number_card.py +++ b/frappe/desk/doctype/number_card/number_card.py @@ -200,7 +200,7 @@ def get_cards_for_user(doctype, txt, searchfield, start, page_len, filters): if txt: search_conditions = [numberCard[field].like(f"%{txt}%") for field in searchfields] - condition_query = frappe.qb.get_query(doctype, filters) + condition_query = frappe.qb.get_query(doctype, filters=filters) return ( condition_query.select(numberCard.name, numberCard.label, numberCard.document_type) diff --git a/frappe/desk/listview.py b/frappe/desk/listview.py index 8b514444df..05d45ad9ac 100644 --- a/frappe/desk/listview.py +++ b/frappe/desk/listview.py @@ -36,7 +36,7 @@ def get_group_by_count(doctype: str, current_filters: str, field: str) -> list[d ToDo = DocType("ToDo") User = DocType("User") count = Count("*").as_("count") - filtered_records = frappe.qb.get_query(doctype, filters=current_filters).select("name") + filtered_records = frappe.qb.get_query(doctype, filters=current_filters, fields=["name"]) return ( frappe.qb.from_(ToDo) From 7ca39a81bfbd9a79d85ae6db1e3cf4a99716ce71 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 31 Dec 2022 22:18:07 +0530 Subject: [PATCH 011/407] fix: explicitly specifiy order --- frappe/tests/test_query.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/tests/test_query.py b/frappe/tests/test_query.py index 957d76d022..486bf9fe49 100644 --- a/frappe/tests/test_query.py +++ b/frappe/tests/test_query.py @@ -275,7 +275,7 @@ class TestQuery(FrappeTestCase): "Test Tree DocType", fields=["name"], filters={"name": ("descendants of", "Parent 1")}, - order_by="modified", + order_by="modified desc", ).run(as_list=1) # Format decendants result @@ -286,7 +286,7 @@ class TestQuery(FrappeTestCase): "Test Tree DocType", fields=["name"], filters={"name": ("ancestors of", "Child 2")}, - order_by="modified", + order_by="modified desc", ).run(as_list=1) # Format ancestors result @@ -297,7 +297,7 @@ class TestQuery(FrappeTestCase): "Test Tree DocType", fields=["name"], filters={"name": ("not descendants of", "Parent 1")}, - order_by="modified", + order_by="modified desc", ).run(as_dict=1) self.assertListEqual( @@ -313,7 +313,7 @@ class TestQuery(FrappeTestCase): "Test Tree DocType", fields=["name"], filters={"name": ("not ancestors of", "Child 2")}, - order_by="modified", + order_by="modified desc", ).run(as_dict=1) self.assertListEqual( From b7c0ba1beaa2cadc5f6bca52ec4e0943c47681d0 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Sat, 31 Dec 2022 22:55:00 +0530 Subject: [PATCH 012/407] fix: allow dynamic fields in filters e.g., `filters={'link.field': 'value'}` `filters={'child.field': 'value'}` --- frappe/database/query.py | 53 +++++++++++++++++++++++++++++++------- frappe/tests/test_query.py | 43 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index b9e60043a0..3593de7c74 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -243,7 +243,7 @@ class Engine: for field in self.fields: if isinstance(field, DynamicTableField): - self.query = field.apply(self.query) + self.query = field.apply_select(self.query) else: self.query = self.query.select(field) @@ -318,18 +318,25 @@ class Engine: _value = value _operator = operator - if has_function(field): + if dynamic_field := DynamicTableField.parse(field, self.doctype): + # apply implicit join if link field's field is referenced + self.query = dynamic_field.apply_join(self.query) + _field = dynamic_field.field + elif has_function(field): _field = self.get_function_object(field) elif not doctype or doctype == self.doctype: _field = self.table[field] elif doctype: _field = self.get_table(doctype)[field] - # keep track of implicit join if child table is referenced + # apply implicit join if child table is referenced if doctype and doctype != self.doctype: meta = frappe.get_meta(doctype) - if meta.istable: - self.implicit_joins.add((doctype, "child")) + table = self.get_table(doctype) + if meta.istable and not self.query.is_joined(table): + self.query = self.query.left_join(table).on( + (table.parent == self.table.name) & (table.parenttype == self.doctype) + ) if isinstance(_value, (str, int)): _value = str(_value) @@ -586,19 +593,38 @@ class DynamicTableField: elif linked_field.fieldtype in frappe.model.table_fields: return ChildTableField(linked_doctype, fieldname, doctype, alias=alias) - def apply(self, query: QueryBuilder) -> QueryBuilder: + def apply_select(self, query: QueryBuilder) -> QueryBuilder: raise NotImplementedError class ChildTableField(DynamicTableField): - def apply(self, query: QueryBuilder) -> QueryBuilder: + def __init__( + self, + doctype: str, + fieldname: str, + parent_doctype: str, + alias: str | None = None, + ) -> None: + self.doctype = doctype + self.fieldname = fieldname + self.alias = alias + self.parent_doctype = parent_doctype + self.table = frappe.qb.DocType(self.doctype) + self.field = self.table[self.fieldname] + + def apply_select(self, query: QueryBuilder) -> QueryBuilder: + table = frappe.qb.DocType(self.doctype) + query = self.apply_join(query) + return query.select(getattr(table, self.fieldname).as_(self.alias or None)) + + def apply_join(self, query: QueryBuilder) -> QueryBuilder: table = frappe.qb.DocType(self.doctype) main_table = frappe.qb.DocType(self.parent_doctype) if not query.is_joined(table): query = query.left_join(table).on( (table.parent == main_table.name) & (table.parenttype == self.parent_doctype) ) - return query.select(getattr(table, self.fieldname).as_(self.alias or None)) + return query class LinkTableField(DynamicTableField): @@ -612,10 +638,17 @@ class LinkTableField(DynamicTableField): ) -> None: super().__init__(doctype, fieldname, parent_doctype, alias=alias) self.link_fieldname = link_fieldname + self.table = frappe.qb.DocType(self.doctype) + self.field = self.table[self.fieldname] - def apply(self, query: QueryBuilder) -> QueryBuilder: + def apply_select(self, query: QueryBuilder) -> QueryBuilder: + table = frappe.qb.DocType(self.doctype) + query = self.apply_join(query) + return query.select(getattr(table, self.fieldname).as_(self.alias or None)) + + def apply_join(self, query: QueryBuilder) -> QueryBuilder: table = frappe.qb.DocType(self.doctype) main_table = frappe.qb.DocType(self.parent_doctype) if not query.is_joined(table): query = query.left_join(table).on(table.name == getattr(main_table, self.link_fieldname)) - return query.select(getattr(table, self.fieldname).as_(self.alias or None)) + return query diff --git a/frappe/tests/test_query.py b/frappe/tests/test_query.py index 486bf9fe49..12cb6446d2 100644 --- a/frappe/tests/test_query.py +++ b/frappe/tests/test_query.py @@ -225,6 +225,39 @@ class TestQuery(FrappeTestCase): frappe.qb.from_("User").select(Max(Field("name"))).where(Ifnull("name", "") < Now()).run(), ) + self.assertEqual( + frappe.qb.get_query( + "DocType", + fields=["name"], + filters={"module.app_name": "frappe"}, + ).get_sql(), + "SELECT `tabDocType`.`name` FROM `tabDocType` LEFT JOIN `tabModule Def` ON `tabModule Def`.`name`=`tabDocType`.`module` WHERE `tabModule Def`.`app_name`='frappe'".replace( + "`", '"' if frappe.db.db_type == "postgres" else "`" + ), + ) + + self.assertEqual( + frappe.qb.get_query( + "DocType", + fields=["name"], + filters={"module.app_name": ("like", "frap%")}, + ).get_sql(), + "SELECT `tabDocType`.`name` FROM `tabDocType` LEFT JOIN `tabModule Def` ON `tabModule Def`.`name`=`tabDocType`.`module` WHERE `tabModule Def`.`app_name` LIKE 'frap%'".replace( + "`", '"' if frappe.db.db_type == "postgres" else "`" + ), + ) + + self.assertEqual( + frappe.qb.get_query( + "DocType", + fields=["name"], + filters={"permissions.role": "System Manager"}, + ).get_sql(), + "SELECT `tabDocType`.`name` FROM `tabDocType` LEFT JOIN `tabDocPerm` ON `tabDocPerm`.`parent`=`tabDocType`.`name` AND `tabDocPerm`.`parenttype`='DocType' WHERE `tabDocPerm`.`role`='System Manager'".replace( + "`", '"' if frappe.db.db_type == "postgres" else "`" + ), + ) + def test_implicit_join_query(self): self.maxDiff = None @@ -261,6 +294,16 @@ class TestQuery(FrappeTestCase): ), ) + self.assertEqual( + frappe.qb.get_query( + "DocType", + fields=["name", "module.app_name as app_name"], + ).get_sql(), + "SELECT `tabDocType`.`name`,`tabModule Def`.`app_name` `app_name` FROM `tabDocType` LEFT JOIN `tabModule Def` ON `tabModule Def`.`name`=`tabDocType`.`module`".replace( + "`", '"' if frappe.db.db_type == "postgres" else "`" + ), + ) + @run_only_if(db_type_is.MARIADB) def test_comment_stripping(self): self.assertNotIn( From 9d6f82725eb9fcd4b4e51ee9c5d7230b5a9973d6 Mon Sep 17 00:00:00 2001 From: phot0n Date: Sun, 1 Jan 2023 21:22:36 +0530 Subject: [PATCH 013/407] chore: remove tracking changes for token cache happens very frequently for email and leads to unnecessary bloat in version doctype plus token caches are only read and delete only --- frappe/integrations/doctype/token_cache/token_cache.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/integrations/doctype/token_cache/token_cache.json b/frappe/integrations/doctype/token_cache/token_cache.json index c016405031..0e6601fd98 100644 --- a/frappe/integrations/doctype/token_cache/token_cache.json +++ b/frappe/integrations/doctype/token_cache/token_cache.json @@ -86,10 +86,11 @@ } ], "links": [], - "modified": "2020-11-13 13:35:53.714352", + "modified": "2023-01-01 21:01:24.405729", "modified_by": "Administrator", "module": "Integrations", "name": "Token Cache", + "naming_rule": "Expression", "owner": "Administrator", "permissions": [ { @@ -106,5 +107,5 @@ ], "sort_field": "modified", "sort_order": "DESC", - "track_changes": 1 + "states": [] } \ No newline at end of file From 52daef0dfd30b8b0a0559cefdd7acfa521f197eb Mon Sep 17 00:00:00 2001 From: phot0n Date: Sun, 1 Jan 2023 22:43:33 +0530 Subject: [PATCH 014/407] fix: get_expires_in logic for token cache --- frappe/integrations/doctype/token_cache/token_cache.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frappe/integrations/doctype/token_cache/token_cache.py b/frappe/integrations/doctype/token_cache/token_cache.py index b01974e5ab..034b1d9107 100644 --- a/frappe/integrations/doctype/token_cache/token_cache.py +++ b/frappe/integrations/doctype/token_cache/token_cache.py @@ -52,11 +52,10 @@ class TokenCache(Document): return self def get_expires_in(self): + modified = frappe.utils.get_datetime(self.modified) + expiry_utc = modified.astimezone(pytz.utc) + timedelta(seconds=self.expires_in) now_utc = datetime.utcnow().replace(tzinfo=pytz.utc) - expiry_time = frappe.utils.get_datetime(self.modified) + timedelta(seconds=self.expires_in) - expiry_local = expiry_time.replace(tzinfo=pytz.timezone(frappe.utils.get_time_zone())) - expiry_utc = expiry_local.astimezone(pytz.utc) - return (expiry_utc - now_utc).total_seconds() + return cint((expiry_utc - now_utc).total_seconds()) def is_expired(self): return self.get_expires_in() < 0 From f50be4bbf5ae9396dabf3e5e31ed1e40e9fcf8d9 Mon Sep 17 00:00:00 2001 From: phot0n Date: Sun, 1 Jan 2023 23:00:03 +0530 Subject: [PATCH 015/407] chore: log any exception while refreshing access token in connected apps --- .../doctype/connected_app/connected_app.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index e8193c89f2..ff2eb2dc96 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -107,11 +107,17 @@ class ConnectedApp(Document): token_cache = self.get_token_cache(user) if token_cache and token_cache.is_expired(): oauth_session = self.get_oauth2_session(user) - token = oauth_session.refresh_token( - body=f"redirect_uri={self.redirect_uri}", - token_url=self.token_uri, - refresh_token=token_cache.get_password("refresh_token"), - ) + + try: + token = oauth_session.refresh_token( + body=f"redirect_uri={self.redirect_uri}", + token_url=self.token_uri, + refresh_token=token_cache.get_password("refresh_token"), + ) + except Exception: + self.log_error("Token Refresh Error") + return None + token_cache.update_data(token) return token_cache From 6bed904bf7f0145dd12abff1f45327e4e3aa6fee Mon Sep 17 00:00:00 2001 From: phot0n Date: Tue, 3 Jan 2023 18:05:49 +0530 Subject: [PATCH 016/407] fix: only validate oauth if tokens are set * also brough back oauth authorization message --- .../doctype/email_account/email_account.js | 23 +++++++++++++++++++ .../doctype/email_account/email_account.py | 3 ++- .../doctype/connected_app/connected_app.py | 1 - 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index 31da072692..f139c23ce6 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -74,6 +74,7 @@ function oauth_access(frm) { method: "initiate_web_application_flow", args: { success_uri: window.location.pathname, + user: frm.doc.connected_user, }, callback: function (r) { window.open(r.message, "_self"); @@ -147,6 +148,7 @@ frappe.ui.form.on("Email Account", { frm.refresh_field("imap_folder"); } set_default_max_attachment_size(frm); + frm.events.show_oauth_authorization_message(frm); }, refresh: function (frm) { @@ -180,6 +182,27 @@ frappe.ui.form.on("Email Account", { oauth_access(frm); }, + show_oauth_authorization_message(frm) { + if (frm.doc.auth_method === "OAuth") { + frappe.call({ + method: "frappe.integrations.doctype.connected_app.connected_app.check_active_token", + args: { + connected_app: frm.doc.connected_app, + connected_user: frm.doc.connected_user, + }, + callback: (r) => { + if (!r.message) { + let msg = __( + 'OAuth has been enabled but not authorised. Please use "Authorise API Access" button to do the same.' + ); + frm.dashboard.clear_headline(); + frm.dashboard.set_headline_alert(msg, "yellow"); + } + }, + }); + } + }, + domain: frappe.utils.debounce((frm) => { if (frm.doc.domain) { frappe.call({ diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index a0fc8f162e..66f7e1c688 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -82,6 +82,7 @@ class EmailAccount(Document): return use_oauth = self.auth_method == "OAuth" + validate_oauth = use_oauth and not (self.is_new() and not self.get_oauth_token()) self.use_starttls = cint(self.use_imap and self.use_starttls and not self.use_ssl) if use_oauth: @@ -90,7 +91,7 @@ class EmailAccount(Document): self.password = None if not frappe.local.flags.in_install and not self.awaiting_password: - if use_oauth or self.password or self.smtp_server in ("127.0.0.1", "localhost"): + if validate_oauth or self.password or self.smtp_server in ("127.0.0.1", "localhost"): if self.enable_incoming: self.get_incoming_server() self.no_failed = 0 diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index ff2eb2dc96..f78ccd59ce 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -112,7 +112,6 @@ class ConnectedApp(Document): token = oauth_session.refresh_token( body=f"redirect_uri={self.redirect_uri}", token_url=self.token_uri, - refresh_token=token_cache.get_password("refresh_token"), ) except Exception: self.log_error("Token Refresh Error") From 481d72c0aee32a3c4242f8f5265bc4695287ca94 Mon Sep 17 00:00:00 2001 From: phot0n Date: Fri, 6 Jan 2023 13:07:13 +0530 Subject: [PATCH 017/407] fix: only pull from email accoutns which have token * removed unnecessary after_save client hook from email account * renamed check_active_token -> has_token (no need to refresh the access token) * removed oauthentication error from email oauth - now just a simple validation error --- .../doctype/email_account/email_account.js | 19 +------------------ .../doctype/email_account/email_account.py | 13 +++++++++++-- frappe/email/oauth.py | 13 +++++-------- .../doctype/connected_app/connected_app.py | 5 +++-- .../doctype/token_cache/token_cache.py | 4 ++-- 5 files changed, 22 insertions(+), 32 deletions(-) diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index f139c23ce6..bc3d168639 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -161,23 +161,6 @@ frappe.ui.form.on("Email Account", { } }, - after_save(frm) { - if (frm.doc.auth_method === "OAuth") { - frappe.call({ - method: "frappe.integrations.doctype.connected_app.connected_app.check_active_token", - args: { - connected_app: frm.doc.connected_app, - connected_user: frm.doc.connected_user, - }, - callback: (r) => { - if (!r.message) { - oauth_access(frm); - } - }, - }); - } - }, - authorize_api_access: function (frm) { oauth_access(frm); }, @@ -185,7 +168,7 @@ frappe.ui.form.on("Email Account", { show_oauth_authorization_message(frm) { if (frm.doc.auth_method === "OAuth") { frappe.call({ - method: "frappe.integrations.doctype.connected_app.connected_app.check_active_token", + method: "frappe.integrations.doctype.connected_app.connected_app.has_token", args: { connected_app: frm.doc.connected_app, connected_user: frm.doc.connected_user, diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 66f7e1c688..1e08ce5615 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -776,15 +776,24 @@ def pull(now=False): else: return + from frappe.integrations.doctype.connected_app.connected_app import has_token + doctype = frappe.qb.DocType("Email Account") email_accounts = ( frappe.qb.from_(doctype) - .select(doctype.name) + .select(doctype.name, doctype.auth_method, doctype.connected_app, doctype.connected_user) .where(doctype.enable_incoming == 1) - .where((doctype.awaiting_password == 0) | (doctype.auth_method == "OAuth")) + .where(doctype.awaiting_password == 0) .run(as_dict=1) ) + for email_account in email_accounts: + if email_account.auth_method == "OAuth" and not has_token( + email_account.connected_app, email_account.connected_user + ): + # don't try to pull from accounts which dont have access token (for Oauth) + continue + if now: pull_from_email_account(email_account.name) diff --git a/frappe/email/oauth.py b/frappe/email/oauth.py index ad951d770e..6b56047069 100644 --- a/frappe/email/oauth.py +++ b/frappe/email/oauth.py @@ -6,10 +6,6 @@ from smtplib import SMTP import frappe -class OAuthenticationError(Exception): - pass - - class Oauth: def __init__( self, @@ -32,8 +28,7 @@ class Oauth: if not self._access_token: frappe.throw( frappe._("Please Authorize OAuth for Email Account {}").format(self.email_account), - OAuthenticationError, - frappe._("OAuth Error"), + title=frappe._("OAuth Error"), ) @property @@ -53,9 +48,11 @@ class Oauth: # SMTP self._connect_smtp() - except Exception as e: + except Exception: frappe.log_error( - "Email Connection Error - Authentication Failed", str(e), "Email Account", self.email_account + "Email Connection Error - Authentication Failed", + reference_doctype="Email Account", + reference_name=self.email_account, ) # raising a bare exception here as we have a lot of exception handling present # where the connect method is called from - hence just logging and raising. diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index f78ccd59ce..ca9677d4da 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -162,6 +162,7 @@ def callback(code=None, state=None): @frappe.whitelist() -def check_active_token(connected_app, connected_user=None): +def has_token(connected_app, connected_user=None): app = frappe.get_doc("Connected App", connected_app) - return bool(app.get_active_token(connected_user)) + token_cache = app.get_token_cache(connected_user or frappe.session.user) + return bool(token_cache.get_json()["access_token"]) diff --git a/frappe/integrations/doctype/token_cache/token_cache.py b/frappe/integrations/doctype/token_cache/token_cache.py index 034b1d9107..2facc006c6 100644 --- a/frappe/integrations/doctype/token_cache/token_cache.py +++ b/frappe/integrations/doctype/token_cache/token_cache.py @@ -62,8 +62,8 @@ class TokenCache(Document): def get_json(self): return { - "access_token": self.get_password("access_token", ""), - "refresh_token": self.get_password("refresh_token", ""), + "access_token": self.get_password("access_token", False), + "refresh_token": self.get_password("refresh_token", False), "expires_in": self.get_expires_in(), "token_type": self.token_type, } From e4ac91a035faf88560911057b0e8cf926a619ad9 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 9 Jan 2023 15:20:30 +0530 Subject: [PATCH 018/407] fix: ignore string with parenthesis if it is not an sql function --- frappe/database/query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index 3593de7c74..ac23097226 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -140,7 +140,7 @@ def literal_eval_(literal): def has_function(field): _field = field.casefold() if (isinstance(field, str) and "`" not in field) else field if not issubclass(type(_field), Criterion): - if any([f"{func}(" in _field for func in SQL_FUNCTIONS]) or "(" in _field: + if any([f"{func}(" in _field for func in SQL_FUNCTIONS]): return True From 35c2654f005a60495978f6588e2eb4be64f87872 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 9 Jan 2023 15:34:50 +0530 Subject: [PATCH 019/407] chore: indentation fix --- 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 c9c37fda30..c104c9cf9c 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -887,7 +887,7 @@ class Database: field, val, modified=modified, modified_by=modified_by, update_modified=update_modified ) - query = frappe.qb.get_query(table=dt, filters=dn, update=True) + query = frappe.qb.get_query(table=dt, filters=dn, update=True) if isinstance(dn, str): frappe.clear_document_cache(dt, dn) From 84ccf3d1287952bf84e4a4333ea2ccd9020b5be0 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 26 Dec 2022 16:18:14 +0530 Subject: [PATCH 020/407] fix: Apply field permlevel for doc GET via REST --- frappe/api.py | 1 + frappe/client.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/frappe/api.py b/frappe/api.py index 309adbc564..084bee060b 100644 --- a/frappe/api.py +++ b/frappe/api.py @@ -131,6 +131,7 @@ class _RESTAPIHandler: doc = frappe.get_doc(self.doctype, self.name) if not doc.has_permission("read"): raise frappe.PermissionError + doc.apply_fieldlevel_read_permissions() frappe.local.response.update({"data": doc}) def update_doc(self): diff --git a/frappe/client.py b/frappe/client.py index 4dc118ea06..b09f9168f4 100644 --- a/frappe/client.py +++ b/frappe/client.py @@ -86,6 +86,8 @@ def get(doctype, name=None, filters=None, parent=None): doc = frappe.get_doc(doctype) # single doc.check_permission() + doc.apply_fieldlevel_read_permissions() + return doc.as_dict() From c28e4590e88a3e2ebcdb8d4d0ddaeaee3d944793 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 26 Dec 2022 16:19:48 +0530 Subject: [PATCH 021/407] fix(rest): Delete doc attr if insufficient field permissions --- frappe/model/base_document.py | 9 ++++++++- frappe/model/document.py | 8 ++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 79899caf14..bbe61ca74e 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -32,6 +32,7 @@ DOCTYPE_TABLE_FIELDS = [ TABLE_DOCTYPES_FOR_DOCTYPE = {df["fieldname"]: df["options"] for df in DOCTYPE_TABLE_FIELDS} DOCTYPES_FOR_DOCTYPE = {"DocType", *TABLE_DOCTYPES_FOR_DOCTYPE.values()} +_DOC_DELETED_ATTR = object() def get_controller(doctype): @@ -298,8 +299,14 @@ class BaseDocument: ) -> dict: d = _dict() for fieldname in self.meta.get_valid_columns(): + field_value = getattr(self, fieldname, _DOC_DELETED_ATTR) + + # don't set if field is deleted + if field_value is _DOC_DELETED_ATTR: + continue + # column is valid, we can use getattr - d[fieldname] = getattr(self, fieldname, None) + d[fieldname] = field_value # if no need for sanitization and value is None, continue if not sanitize and d[fieldname] is None: diff --git a/frappe/model/document.py b/frappe/model/document.py index 7222cf4ad6..c970170a6c 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -675,14 +675,14 @@ class Document(BaseDocument): has_access_to = self.get_permlevel_access("read") for df in self.meta.fields: - if df.permlevel and not df.permlevel in has_access_to: - self.set(df.fieldname, None) + if df.permlevel and df.permlevel not in has_access_to: + delattr(self, df.fieldname) for table_field in self.meta.get_table_fields(): for df in frappe.get_meta(table_field.options).fields or []: - if df.permlevel and not df.permlevel in has_access_to: + if df.permlevel and df.permlevel not in has_access_to: for child in self.get(table_field.fieldname) or []: - child.set(df.fieldname, None) + delattr(child, df.fieldname) def validate_higher_perm_levels(self): """If the user does not have permissions at permlevel > 0, then reset the values to original / default""" From ee074ec3c06b49ec97f5c72632cfe963f5063371 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 26 Dec 2022 16:21:41 +0530 Subject: [PATCH 022/407] perf: DatabaseQuery.prepare_args * Re-use stripped str variable where possible * Remove use of any + [], startswith to get rid of unnecessary evaluations --- frappe/model/db_query.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 1d156d0d1a..60a8887cd4 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -261,16 +261,14 @@ class DatabaseQuery: # TODO: Add support for wrapping fields with sql functions and distinct keyword for field in self.fields: stripped_field = field.strip().lower() - skip_wrapping = any( - [ - stripped_field.startswith(("`", "*", '"', "'")), - "(" in stripped_field, - "distinct" in stripped_field, - ] - ) - if skip_wrapping: + + if ( + stripped_field[0] in {"`", "*", '"', "'"} + or "(" in stripped_field + or "distinct" in stripped_field + ): fields.append(field) - elif "as" in field.lower().split(" "): + elif "as" in stripped_field.split(" "): col, _, new = field.split() fields.append(f"`{col}` as {new}") else: From d71522091ed2311648496e264a6c0e4dfa4b363a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 27 Dec 2022 13:20:56 +0530 Subject: [PATCH 023/407] fix: Apply permlevel restrictions to DatabaseQuery Allow reading only accessible fields for given user session if ignore_permissions (get_all) is unset. --- frappe/model/db_query.py | 22 ++++++++++++++++++++++ frappe/model/meta.py | 12 ++++++++++++ 2 files changed, 34 insertions(+) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 60a8887cd4..d79bdbada0 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -224,6 +224,7 @@ class DatabaseQuery: self.extract_tables() self.set_optional_columns() self.build_conditions() + self.apply_fieldlevel_read_permissions() args = frappe._dict() @@ -548,6 +549,27 @@ class DatabaseQuery: else: conditions.append(self.prepare_filter_condition(f)) + def apply_fieldlevel_read_permissions(self): + """Apply fieldlevel read permissions to the query""" + if self.flags.ignore_permissions: + return + + accessible_fields = { + x.fieldname for x in frappe.get_meta(self.doctype).get_permlevel_read_fields() + } + + for i, field in enumerate(self.fields): + if field == "*": + self.fields.pop(i) + for f in accessible_fields: + self.fields.insert(i, f"`tab{self.doctype}`.`{f}`") + + elif field[0] in {"'", '"', "_"} or "(" in field or "." in field or field in accessible_fields: + continue + + else: + self.fields.remove(field) + def prepare_filter_condition(self, f): """Returns a filter condition in the format: ifnull(`tabDocType`.`fieldname`, fallback) operator "value" diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 69a2cbd11e..7ef037521f 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -530,6 +530,18 @@ class Meta(Document): return self.high_permlevel_fields + def get_permlevel_read_fields(self, parenttype=None, *, user=None): + """Build list of fields with read perm level and all the higher perm levels defined.""" + if not hasattr(self, "permlevel_read_fields"): + self.permlevel_read_fields = [] + permlevel_access = set(self.get_permlevel_access("read", parenttype, user=user)) + + for df in self.get_fieldnames_with_value(with_field_meta=True): + if df.permlevel in permlevel_access: + self.permlevel_read_fields.append(df) + + return self.permlevel_read_fields + def get_permlevel_access(self, permission_type="read", parenttype=None, *, user=None): has_access_to = [] roles = frappe.get_roles(user) From 48aa7e8a934444c16bf563772435c416d0cd92cf Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 27 Dec 2022 13:32:51 +0530 Subject: [PATCH 024/407] perf(db_query): Avoid re-fetching doctype meta Store doctype meta in DatabaseQuery instance under `doctype_meta` bypassing multiple get_meta calls and Redis/DB IO & serialization overheads. --- frappe/model/db_query.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index d79bdbada0..29fbe3945d 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -55,6 +55,7 @@ ORDER_GROUP_PATTERN = re.compile(r".*[^a-z0-9-_ ,`'\"\.\(\)].*") class DatabaseQuery: def __init__(self, doctype, user=None): self.doctype = doctype + self.doctype_meta = frappe.get_meta(doctype) self.tables = [] self.link_tables = [] self.conditions = [] @@ -322,7 +323,7 @@ class DatabaseQuery: if " as " in field: field, alias = field.split(" as ") linked_fieldname, fieldname = field.split(".") - linked_field = frappe.get_meta(self.doctype).get_field(linked_fieldname) + linked_field = self.doctype_meta.get_field(linked_fieldname) linked_doctype = linked_field.options if linked_field.fieldtype == "Link": self.append_link_table(linked_doctype, linked_fieldname) @@ -554,9 +555,7 @@ class DatabaseQuery: if self.flags.ignore_permissions: return - accessible_fields = { - x.fieldname for x in frappe.get_meta(self.doctype).get_permlevel_read_fields() - } + accessible_fields = {x.fieldname for x in self.doctype_meta.get_permlevel_read_fields()} for i, field in enumerate(self.fields): if field == "*": @@ -758,12 +757,11 @@ class DatabaseQuery: if not self.tables: self.extract_tables() - meta = frappe.get_meta(self.doctype) - role_permissions = frappe.permissions.get_role_permissions(meta, user=self.user) + role_permissions = frappe.permissions.get_role_permissions(self.doctype_meta, user=self.user) self.shared = frappe.share.get_shared(self.doctype, self.user) if ( - not meta.istable + not self.doctype_meta.istable and not (role_permissions.get("select") or role_permissions.get("read")) and not self.flags.ignore_permissions and not has_any_user_permission_for_doctype(self.doctype, self.user, self.reference_doctype) @@ -813,9 +811,8 @@ class DatabaseQuery: ) def add_user_permissions(self, user_permissions): - meta = frappe.get_meta(self.doctype) doctype_link_fields = [] - doctype_link_fields = meta.get_link_fields() + doctype_link_fields = self.doctype_meta.get_link_fields() # append current doctype with fieldname as 'name' as first link field doctype_link_fields.append( @@ -890,8 +887,6 @@ class DatabaseQuery: return " and ".join(conditions) if conditions else "" def set_order_by(self, args): - meta = frappe.get_meta(self.doctype) - if self.order_by and self.order_by != "KEEP_DEFAULT_ORDERING": args.order_by = self.order_by else: @@ -910,7 +905,7 @@ class DatabaseQuery: if not group_function_without_group_by: sort_field = sort_order = None - if meta.sort_field and "," in meta.sort_field: + if self.doctype_meta.sort_field and "," in self.doctype_meta.sort_field: # multiple sort given in doctype definition # Example: # `idx desc, modified desc` @@ -918,16 +913,16 @@ class DatabaseQuery: # `tabItem`.`idx` desc, `tabItem`.`modified` desc args.order_by = ", ".join( f"`tab{self.doctype}`.`{f.split()[0].strip()}` {f.split()[1].strip()}" - for f in meta.sort_field.split(",") + for f in self.doctype_meta.sort_field.split(",") ) else: - sort_field = meta.sort_field or "modified" - sort_order = (meta.sort_field and meta.sort_order) or "desc" + sort_field = self.doctype_meta.sort_field or "modified" + sort_order = (self.doctype_meta.sort_field and self.doctype_meta.sort_order) or "desc" if self.order_by: args.order_by = f"`tab{self.doctype}`.`{sort_field or 'modified'}` {sort_order or 'desc'}" # draft docs always on top - if hasattr(meta, "is_submittable") and meta.is_submittable: + if hasattr(self.doctype_meta, "is_submittable") and self.doctype_meta.is_submittable: if self.order_by: args.order_by = f"`tab{self.doctype}`.docstatus asc, {args.order_by}" From 1cd7620a3cf31d57f9906bd186a17f0704c6e3f7 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 30 Dec 2022 00:19:33 +0530 Subject: [PATCH 025/407] fix: Fetch fields according to meta maintain order --- frappe/model/db_query.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 29fbe3945d..29a5fbd401 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -555,13 +555,32 @@ class DatabaseQuery: if self.flags.ignore_permissions: return - accessible_fields = {x.fieldname for x in self.doctype_meta.get_permlevel_read_fields()} + accessible_fields = [x.fieldname for x in self.doctype_meta.get_permlevel_read_fields()] + meta_fields = self.doctype_meta.default_fields.copy() + optional_meta_fields = list(optional_fields) + + if not self.doctype_meta.track_seen: + optional_meta_fields.remove("_seen") + + if not self.doctype_meta.is_submittable: + meta_fields.remove("docstatus") + + if self.doctype_meta.istable: + meta_fields.append("parent") + meta_fields.append("parenttype") + meta_fields.append("parentfield") + else: + meta_fields.remove("idx") + + available_fields = meta_fields + accessible_fields + optional_meta_fields for i, field in enumerate(self.fields): if field == "*": self.fields.pop(i) - for f in accessible_fields: - self.fields.insert(i, f"`tab{self.doctype}`.`{f}`") + k = i + for f in available_fields: + self.fields.insert(k, f"`tab{self.doctype}`.`{f}`") + k += 1 elif field[0] in {"'", '"', "_"} or "(" in field or "." in field or field in accessible_fields: continue From 15e51307b1eb5e1516b9339e1e49a11015954840 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 3 Jan 2023 14:00:09 +0530 Subject: [PATCH 026/407] fix(db_query): Maintain order of dict[/select] keys * Reduce internals' mutating calls * maintain order of fields as previous function * Use performant f-strings over concat + formatting --- frappe/model/db_query.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 29a5fbd401..e5df0e31f2 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -231,8 +231,8 @@ class DatabaseQuery: if self.with_childnames: for t in self.tables: - if t != "`tab" + self.doctype + "`": - self.fields.append(t + ".name as '%s:name'" % t[4:-1]) + if t != f"`tab{self.doctype}`": + self.fields.append(f"{t}.name as '{t[4:-1]}:name'") # query dict args.tables = self.tables[0] @@ -536,7 +536,7 @@ class DatabaseQuery: if match_conditions: self.conditions.append(f"({match_conditions})") - def build_filter_conditions(self, filters, conditions, ignore_permissions=None): + def build_filter_conditions(self, filters, conditions: list, ignore_permissions=None): """build conditions from user filters""" if ignore_permissions is not None: self.flags.ignore_permissions = ignore_permissions @@ -566,9 +566,7 @@ class DatabaseQuery: meta_fields.remove("docstatus") if self.doctype_meta.istable: - meta_fields.append("parent") - meta_fields.append("parenttype") - meta_fields.append("parentfield") + meta_fields.extend(["parent", "parenttype", "parentfield"]) else: meta_fields.remove("idx") @@ -576,11 +574,7 @@ class DatabaseQuery: for i, field in enumerate(self.fields): if field == "*": - self.fields.pop(i) - k = i - for f in available_fields: - self.fields.insert(k, f"`tab{self.doctype}`.`{f}`") - k += 1 + self.fields[i : i + 1] = available_fields elif field[0] in {"'", '"', "_"} or "(" in field or "." in field or field in accessible_fields: continue From 1f503705990f299af8e2e9ed97ea76c0beb03d7e Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Jan 2023 16:12:22 +0530 Subject: [PATCH 027/407] fix(db_query): Apply permlevel checks on child/joined table queries --- frappe/model/db_query.py | 58 ++++++++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index e5df0e31f2..ea73f8ef88 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -555,30 +555,34 @@ class DatabaseQuery: if self.flags.ignore_permissions: return - accessible_fields = [x.fieldname for x in self.doctype_meta.get_permlevel_read_fields()] - meta_fields = self.doctype_meta.default_fields.copy() - optional_meta_fields = list(optional_fields) - - if not self.doctype_meta.track_seen: - optional_meta_fields.remove("_seen") - - if not self.doctype_meta.is_submittable: - meta_fields.remove("docstatus") - - if self.doctype_meta.istable: - meta_fields.extend(["parent", "parenttype", "parentfield"]) - else: - meta_fields.remove("idx") - - available_fields = meta_fields + accessible_fields + optional_meta_fields + available_fields = get_available_fields(doctype=self.doctype) for i, field in enumerate(self.fields): if field == "*": self.fields[i : i + 1] = available_fields - elif field[0] in {"'", '"', "_"} or "(" in field or "." in field or field in accessible_fields: + # labels / pseudo columns or frappe internals + elif field[0] in {"'", '"', "_"} or field in available_fields: continue + # handle child / joined table fields + elif "." in field: + table, _column = field.split(".", 1) + column = _column.lower().split(" ", 1)[0].replace("`", "") + + if table in self.tables: + ch_doctype = table.replace("`", "").replace("tab", "", 1) + available_child_table_fields = get_available_fields(doctype=ch_doctype) + if column in available_child_table_fields: + continue + else: + self.fields.remove(field) + + # field inside function calls + elif "(" in field and any(x for x in available_fields if x in field): + continue + + # remove if access not allowed else: self.fields.remove(field) @@ -1176,3 +1180,23 @@ def requires_owner_constraint(role_permissions): # not checking if either select or read if present in if_owner_perms # because either of those is required to perform a query return True + + +def get_available_fields(doctype): + meta = frappe.get_meta(doctype) + accessible_fields = [x.fieldname for x in meta.get_permlevel_read_fields()] + meta_fields = meta.default_fields.copy() + optional_meta_fields = list(optional_fields) + + if not meta.track_seen: + optional_meta_fields.remove("_seen") + + if not meta.is_submittable: + meta_fields.remove("docstatus") + + if meta.istable: + meta_fields.extend(["parent", "parenttype", "parentfield"]) + else: + meta_fields.remove("idx") + + return meta_fields + accessible_fields + optional_meta_fields From 2a6f9b1b9adda00e39b592e2f7f1dcd5108fd817 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Jan 2023 16:22:01 +0530 Subject: [PATCH 028/407] fix(db_query): Load doctype meta on demand not on init --- frappe/model/db_query.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index ea73f8ef88..0badee7882 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -55,7 +55,6 @@ ORDER_GROUP_PATTERN = re.compile(r".*[^a-z0-9-_ ,`'\"\.\(\)].*") class DatabaseQuery: def __init__(self, doctype, user=None): self.doctype = doctype - self.doctype_meta = frappe.get_meta(doctype) self.tables = [] self.link_tables = [] self.conditions = [] @@ -66,6 +65,12 @@ class DatabaseQuery: self.flags = frappe._dict() self.reference_doctype = None + @property + def doctype_meta(self): + if not hasattr(self, "_doctype_meta"): + self._doctype_meta = frappe.get_meta(self.doctype) + return self._doctype_meta + def execute( self, fields=None, From f982439eb9a86561cdb0b9d449ab83a12c56e0a0 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 9 Jan 2023 16:43:44 +0530 Subject: [PATCH 029/407] fix: pass fields explicitly - to prevent addition of default `name` field - also, add fields only if it is a select query --- frappe/database/query.py | 18 +++++++++--------- frappe/query_builder/functions.py | 6 +++--- frappe/utils/goal.py | 11 +++++++---- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index ac23097226..b9dfa33217 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -236,16 +236,16 @@ class Engine: self.query = frappe.qb.from_(self.table).delete() else: self.query = frappe.qb.from_(self.table) + # add fields + self.fields = self.parse_fields(fields) + if not self.fields: + self.fields = [getattr(self.table, pluck or "name")] - self.fields = self.parse_fields(fields) - if not self.fields: - self.fields = [getattr(self.table, pluck or "name")] - - for field in self.fields: - if isinstance(field, DynamicTableField): - self.query = field.apply_select(self.query) - else: - self.query = self.query.select(field) + for field in self.fields: + if isinstance(field, DynamicTableField): + self.query = field.apply_select(self.query) + else: + self.query = self.query.select(field) self.apply_filters(filters) self.apply_implicit_joins() diff --git a/frappe/query_builder/functions.py b/frappe/query_builder/functions.py index e1ab182553..512df8835c 100644 --- a/frappe/query_builder/functions.py +++ b/frappe/query_builder/functions.py @@ -119,9 +119,9 @@ class Cast_(Function): def _aggregate(function, dt, fieldname, filters, **kwargs): return ( - frappe.qb.get_query(dt, filters=filters) - .select(function(PseudoColumn(fieldname))) - .run(**kwargs)[0][0] + frappe.qb.get_query(dt, filters=filters, fields=[function(PseudoColumn(fieldname))]).run( + **kwargs + )[0][0] or 0 ) diff --git a/frappe/utils/goal.py b/frappe/utils/goal.py index 0dcadc5ec6..f60aec4d2b 100644 --- a/frappe/utils/goal.py +++ b/frappe/utils/goal.py @@ -24,10 +24,13 @@ def get_monthly_results( date_format = "%m-%Y" if frappe.db.db_type != "postgres" else "MM-YYYY" return dict( - frappe.qb.get_query(table=goal_doctype, filters=filters) - .select( - DateFormat(Table[date_col], date_format).as_("month_year"), - Function(aggregation, goal_field), + frappe.qb.get_query( + table=goal_doctype, + fields=[ + DateFormat(Table[date_col], date_format).as_("month_year"), + Function(aggregation, goal_field), + ], + filters=filters, ) .groupby("month_year") .run() From 058c49f439a5c5922391d8a78e9805ff233ec4b2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Jan 2023 17:09:36 +0530 Subject: [PATCH 030/407] fix: Pass parenttype in meta calls, handle count(*) type queries --- frappe/database/database.py | 2 +- frappe/model/db_query.py | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index 5b8be7d6e2..974f5ccba6 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -221,7 +221,7 @@ class Database: self._cursor.execute(query, values) except Exception as e: if self.is_syntax_error(e): - frappe.errprint(f"Syntax error in query:\n{query} {values}") + frappe.errprint(f"Syntax error in query:\n{query} {values or ''}") elif self.is_deadlocked(e): raise frappe.QueryDeadlockError(e) from e diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 0badee7882..fa51a7eeb3 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -14,7 +14,7 @@ import frappe.share from frappe import _ from frappe.core.doctype.server_script.server_script_utils import get_server_script_map from frappe.database.utils import FallBackDateTimeStr, NestedSetHierarchy -from frappe.model import optional_fields +from frappe.model import core_doctypes_list, optional_fields from frappe.model.meta import get_table_columns from frappe.model.utils.user_settings import get_user_settings, update_user_settings from frappe.query_builder.utils import Column @@ -577,14 +577,16 @@ class DatabaseQuery: if table in self.tables: ch_doctype = table.replace("`", "").replace("tab", "", 1) - available_child_table_fields = get_available_fields(doctype=ch_doctype) + available_child_table_fields = get_available_fields( + doctype=ch_doctype, parenttype=self.doctype + ) if column in available_child_table_fields: continue else: self.fields.remove(field) - # field inside function calls - elif "(" in field and any(x for x in available_fields if x in field): + # field inside function calls / * handles things like count(*) + elif "(" in field and ("*" in field or any(x for x in available_fields if x in field)): continue # remove if access not allowed @@ -1187,9 +1189,13 @@ def requires_owner_constraint(role_permissions): return True -def get_available_fields(doctype): +def get_available_fields(doctype, parenttype=None): meta = frappe.get_meta(doctype) - accessible_fields = [x.fieldname for x in meta.get_permlevel_read_fields()] + + if doctype in core_doctypes_list: + return meta.get_valid_columns() + + accessible_fields = [x.fieldname for x in meta.get_permlevel_read_fields(parenttype=parenttype)] meta_fields = meta.default_fields.copy() optional_meta_fields = list(optional_fields) From 6192a9285a561b0e7d219ca04a36251aaec864a7 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 9 Jan 2023 17:51:55 +0530 Subject: [PATCH 031/407] fix: use Field objects as is in apply_filter --- frappe/database/query.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index b9dfa33217..dc6511bb95 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -318,7 +318,9 @@ class Engine: _value = value _operator = operator - if dynamic_field := DynamicTableField.parse(field, self.doctype): + if isinstance(_field, Field): + pass + elif dynamic_field := DynamicTableField.parse(field, self.doctype): # apply implicit join if link field's field is referenced self.query = dynamic_field.apply_join(self.query) _field = dynamic_field.field From bb9763def769d4031f55abed547fdfe4e036e1c5 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Jan 2023 18:19:03 +0530 Subject: [PATCH 032/407] fix(db_query): Parse SQL function calls to check if field is accessible --- frappe/model/db_query.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index fa51a7eeb3..425a0ade6c 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -50,6 +50,7 @@ FIELD_COMMA_PATTERN = re.compile(r"[0-9a-zA-Z]+\s*,") STRICT_FIELD_PATTERN = re.compile(r".*/\*.*") STRICT_UNION_PATTERN = re.compile(r".*\s(union).*\s") ORDER_GROUP_PATTERN = re.compile(r".*[^a-z0-9-_ ,`'\"\.\(\)].*") +FN_PARAMS_PATTERN = re.compile(r".*?\((.*)\).*") class DatabaseQuery: @@ -563,11 +564,13 @@ class DatabaseQuery: available_fields = get_available_fields(doctype=self.doctype) for i, field in enumerate(self.fields): - if field == "*": + column = field.split(" ", 1)[0].replace("`", "") + + if column == "*": self.fields[i : i + 1] = available_fields # labels / pseudo columns or frappe internals - elif field[0] in {"'", '"', "_"} or field in available_fields: + elif column[0] in {"'", '"', "_"} or column in available_fields: continue # handle child / joined table fields @@ -586,8 +589,18 @@ class DatabaseQuery: self.fields.remove(field) # field inside function calls / * handles things like count(*) - elif "(" in field and ("*" in field or any(x for x in available_fields if x in field)): - continue + elif "(" in field: + if "*" in field: + continue + elif any(x for x in available_fields if x in field): + continue + elif _params := FN_PARAMS_PATTERN.findall(column): + params = (x for x in _params[0].split(",")) + for param in params: + if param in available_fields or param.isnumeric() or "'" in param or '"' in param: + continue + else: + self.fields.remove(field) # remove if access not allowed else: From 9e9de7053cf7c852daa3d631f2f25239d90c2cf9 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 9 Jan 2023 18:19:31 +0530 Subject: [PATCH 033/407] fix: set default order_by direction to desc --- frappe/database/query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index dc6511bb95..eb092c9d0d 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -502,7 +502,7 @@ class Engine: for declaration in order_by.split(","): if _order_by := declaration.strip(): parts = _order_by.split(" ") - order_field, order_direction = parts[0], parts[1] if len(parts) > 1 else "asc" + order_field, order_direction = parts[0], parts[1] if len(parts) > 1 else "desc" order_direction = Order.asc if order_direction.lower() == "asc" else Order.desc self.query = self.query.orderby(order_field, order=order_direction) From ae81cd2dd3cfe681ff4be0d4ca7615089f6e22be Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Jan 2023 18:44:42 +0530 Subject: [PATCH 034/407] fix(doc): Maintain virtual df data in as_valid_dict --- frappe/model/base_document.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index bbe61ca74e..b9cd5def5f 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -301,10 +301,6 @@ class BaseDocument: for fieldname in self.meta.get_valid_columns(): field_value = getattr(self, fieldname, _DOC_DELETED_ATTR) - # don't set if field is deleted - if field_value is _DOC_DELETED_ATTR: - continue - # column is valid, we can use getattr d[fieldname] = field_value @@ -313,14 +309,15 @@ class BaseDocument: continue df = self.meta.get_field(fieldname) + is_virtual_field = getattr(df, "is_virtual", False) if df: - if getattr(df, "is_virtual", False): + if is_virtual_field: if ignore_virtual: del d[fieldname] continue - if d[fieldname] is None and (options := getattr(df, "options", None)): + if d[fieldname] in {None, _DOC_DELETED_ATTR} and (options := getattr(df, "options", None)): from frappe.utils.safe_exec import get_safe_globals d[fieldname] = frappe.safe_eval( @@ -356,6 +353,8 @@ class BaseDocument: if ignore_nulls and d[fieldname] is None: del d[fieldname] + if not is_virtual_field and field_value is _DOC_DELETED_ATTR: + del d[fieldname] return d From 6aea25a6c112de64bbce6910b81c0f6dc99884f0 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 9 Jan 2023 18:48:48 +0530 Subject: [PATCH 035/407] test: Update tests to handle missing attributes based on permlevel --- frappe/tests/test_form_load.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/tests/test_form_load.py b/frappe/tests/test_form_load.py index 283f23080e..e7a2851b5f 100644 --- a/frappe/tests/test_form_load.py +++ b/frappe/tests/test_form_load.py @@ -50,7 +50,8 @@ class TestFormLoad(FrappeTestCase): frappe.set_user(user.name) blog_doc = get_blog(blog.name) - self.assertEqual(blog_doc.published, None) + with self.assertRaises(AttributeError): + blog_doc.published # this will be ignored because user does not # have write access on `published` field (or on permlevel 1 fields) @@ -70,7 +71,8 @@ class TestFormLoad(FrappeTestCase): self.assertEqual(blog_doc.name, blog.name) # since published field has higher permlevel - self.assertEqual(blog_doc.published, None) + with self.assertRaises(AttributeError): + blog_doc.published # this will be ignored because user does not # have write access on `published` field (or on permlevel 1 fields) From 60febc9799e0c45590ed966967c6ec03377cefed Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 9 Jan 2023 19:43:48 +0530 Subject: [PATCH 036/407] fix: list filter filters as list must always be list of list --- frappe/tests/test_db.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index 3962cc746d..ed01af655c 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -545,7 +545,7 @@ class TestDB(FrappeTestCase): self.assertEqual((frappe.db.count("Note")), 2) # simple filters - self.assertEqual((frappe.db.count("Note", ["title", "=", "note1"])), 1) + self.assertEqual((frappe.db.count("Note", [["title", "=", "note1"]])), 1) frappe.get_doc(doctype="Note", title="note3", content="something other").insert() From 08fc5b5c90368d66b5374d7478227b8b8dab3290 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 9 Jan 2023 19:54:26 +0530 Subject: [PATCH 037/407] fix: allow list of dict in filters --- frappe/database/query.py | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index eb092c9d0d..532e03f2c6 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -292,19 +292,22 @@ class Engine: self.apply_dict_filters(filters) elif isinstance(filters, (list, tuple)): - self.apply_list_filters(filters) + for filter in filters: + if isinstance(filter, (str, int, Criterion, dict)): + self.apply_filters(filter) + elif isinstance(filter, (list, tuple)): + self.apply_list_filters(filter) - def apply_list_filters(self, filters: list[list]): - for filter in filters: - if len(filter) == 2: - field, value = filter - self._apply_filter(field, value) - elif len(filter) == 3: - field, operator, value = filter - self._apply_filter(field, value, operator) - elif len(filter) == 4: - doctype, field, operator, value = filter - self._apply_filter(field, value, operator, doctype) + def apply_list_filters(self, filter: list): + if len(filter) == 2: + field, value = filter + self._apply_filter(field, value) + elif len(filter) == 3: + field, operator, value = filter + self._apply_filter(field, value, operator) + elif len(filter) == 4: + doctype, field, operator, value = filter + self._apply_filter(field, value, operator, doctype) def apply_dict_filters(self, filters: dict[str, str | int | list]): for key in filters: From fe13108eecc246c9e0aa91987e3b235ba1098ac2 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Tue, 10 Jan 2023 16:15:08 +0530 Subject: [PATCH 038/407] fix: refactor - move operator map in separate file - remove unnecessary code - organize functions --- frappe/database/operator_map.py | 138 +++++++++++++++ frappe/database/query.py | 305 +++++++------------------------- frappe/query_builder/utils.py | 6 +- 3 files changed, 208 insertions(+), 241 deletions(-) create mode 100644 frappe/database/operator_map.py diff --git a/frappe/database/operator_map.py b/frappe/database/operator_map.py new file mode 100644 index 0000000000..2c8b53dae3 --- /dev/null +++ b/frappe/database/operator_map.py @@ -0,0 +1,138 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + +import operator +from typing import Callable + +import frappe +from frappe.database.utils import NestedSetHierarchy +from frappe.model.db_query import get_timespan_date_range +from frappe.query_builder import Field + + +def like(key: Field, value: str) -> frappe.qb: + """Wrapper method for `LIKE` + + Args: + key (str): field + value (str): criterion + + Returns: + frappe.qb: `frappe.qb object with `LIKE` + """ + return key.like(value) + + +def func_in(key: Field, value: list | tuple) -> frappe.qb: + """Wrapper method for `IN` + + Args: + key (str): field + value (Union[int, str]): criterion + + Returns: + frappe.qb: `frappe.qb object with `IN` + """ + if isinstance(value, str): + value = value.split(",") + return key.isin(value) + + +def not_like(key: Field, value: str) -> frappe.qb: + """Wrapper method for `NOT LIKE` + + Args: + key (str): field + value (str): criterion + + Returns: + frappe.qb: `frappe.qb object with `NOT LIKE` + """ + return key.not_like(value) + + +def func_not_in(key: Field, value: list | tuple | str): + """Wrapper method for `NOT IN` + + Args: + key (str): field + value (Union[int, str]): criterion + + Returns: + frappe.qb: `frappe.qb object with `NOT IN` + """ + if isinstance(value, str): + value = value.split(",") + return key.notin(value) + + +def func_regex(key: Field, value: str) -> frappe.qb: + """Wrapper method for `REGEX` + + Args: + key (str): field + value (str): criterion + + Returns: + frappe.qb: `frappe.qb object with `REGEX` + """ + return key.regex(value) + + +def func_between(key: Field, value: list | tuple) -> frappe.qb: + """Wrapper method for `BETWEEN` + + Args: + key (str): field + value (Union[int, str]): criterion + + Returns: + frappe.qb: `frappe.qb object with `BETWEEN` + """ + return key[slice(*value)] + + +def func_is(key, value): + "Wrapper for IS" + return key.isnotnull() if value.lower() == "set" else key.isnull() + + +def func_timespan(key: Field, value: str) -> frappe.qb: + """Wrapper method for `TIMESPAN` + + Args: + key (str): field + value (str): criterion + + Returns: + frappe.qb: `frappe.qb object with `TIMESPAN` + """ + + return func_between(key, get_timespan_date_range(value)) + + +# default operators +OPERATOR_MAP: dict[str, Callable] = { + "+": operator.add, + "=": operator.eq, + "-": operator.sub, + "!=": operator.ne, + "<": operator.lt, + ">": operator.gt, + "<=": operator.le, + "=<": operator.le, + ">=": operator.ge, + "=>": operator.ge, + "/": operator.truediv, + "*": operator.mul, + "in": func_in, + "not in": func_not_in, + "like": like, + "not like": not_like, + "regex": func_regex, + "between": func_between, + "is": func_is, + "timespan": func_timespan, + "nested_set": NestedSetHierarchy, + # TODO: Add support for custom operators (WIP) - via filters_config hooks +} diff --git a/frappe/database/query.py b/frappe/database/query.py index 532e03f2c6..4852f80739 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -1,20 +1,16 @@ import itertools -import operator import re from ast import literal_eval -from functools import cached_property from types import BuiltinFunctionType -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING import sqlparse -from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder from pypika.queries import QueryBuilder import frappe from frappe import _ -from frappe.database.utils import NestedSetHierarchy, is_pypika_function_object -from frappe.model.db_query import get_timespan_date_range -from frappe.query_builder import Criterion, Field, Order, Table, functions +from frappe.database.operator_map import OPERATOR_MAP +from frappe.query_builder import Criterion, Field, Order, functions from frappe.query_builder.functions import Function, SqlFunctions from frappe.query_builder.utils import PseudoColumnMapper from frappe.utils.data import MARIADB_SPECIFIC_COMMENT @@ -29,186 +25,12 @@ SQL_FUNCTIONS = [sql_function.value for sql_function in SqlFunctions] COMMA_PATTERN = re.compile(r",\s*(?![^()]*\))") -def like(key: Field, value: str) -> frappe.qb: - """Wrapper method for `LIKE` - - Args: - key (str): field - value (str): criterion - - Returns: - frappe.qb: `frappe.qb object with `LIKE` - """ - return key.like(value) - - -def func_in(key: Field, value: list | tuple) -> frappe.qb: - """Wrapper method for `IN` - - Args: - key (str): field - value (Union[int, str]): criterion - - Returns: - frappe.qb: `frappe.qb object with `IN` - """ - if isinstance(value, str): - value = value.split(",") - return key.isin(value) - - -def not_like(key: Field, value: str) -> frappe.qb: - """Wrapper method for `NOT LIKE` - - Args: - key (str): field - value (str): criterion - - Returns: - frappe.qb: `frappe.qb object with `NOT LIKE` - """ - return key.not_like(value) - - -def func_not_in(key: Field, value: list | tuple | str): - """Wrapper method for `NOT IN` - - Args: - key (str): field - value (Union[int, str]): criterion - - Returns: - frappe.qb: `frappe.qb object with `NOT IN` - """ - if isinstance(value, str): - value = value.split(",") - return key.notin(value) - - -def func_regex(key: Field, value: str) -> frappe.qb: - """Wrapper method for `REGEX` - - Args: - key (str): field - value (str): criterion - - Returns: - frappe.qb: `frappe.qb object with `REGEX` - """ - return key.regex(value) - - -def func_between(key: Field, value: list | tuple) -> frappe.qb: - """Wrapper method for `BETWEEN` - - Args: - key (str): field - value (Union[int, str]): criterion - - Returns: - frappe.qb: `frappe.qb object with `BETWEEN` - """ - return key[slice(*value)] - - -def func_is(key, value): - "Wrapper for IS" - return key.isnotnull() if value.lower() == "set" else key.isnull() - - -def func_timespan(key: Field, value: str) -> frappe.qb: - """Wrapper method for `TIMESPAN` - - Args: - key (str): field - value (str): criterion - - Returns: - frappe.qb: `frappe.qb object with `TIMESPAN` - """ - - return func_between(key, get_timespan_date_range(value)) - - -def literal_eval_(literal): - try: - return literal_eval(literal) - except (ValueError, SyntaxError): - return literal - - -def has_function(field): - _field = field.casefold() if (isinstance(field, str) and "`" not in field) else field - if not issubclass(type(_field), Criterion): - if any([f"{func}(" in _field for func in SQL_FUNCTIONS]): - return True - - -def get_nested_set_hierarchy_result(doctype: str, name: str, hierarchy: str): - table = frappe.qb.DocType(doctype) - try: - lft, rgt = frappe.qb.from_(table).select("lft", "rgt").where(table.name == name).run()[0] - except IndexError: - lft, rgt = None, None - - if hierarchy in ("descendants of", "not descendants of"): - result = ( - frappe.qb.from_(table) - .select(table.name) - .where(table.lft > lft) - .where(table.rgt < rgt) - .orderby(table.lft, order=Order.asc) - .run() - ) - else: - # Get ancestor elements of a DocType with a tree structure - result = ( - frappe.qb.from_(table) - .select(table.name) - .where(table.lft < lft) - .where(table.rgt > rgt) - .orderby(table.lft, order=Order.desc) - .run() - ) - return result - - -# default operators -OPERATOR_MAP: dict[str, Callable] = { - "+": operator.add, - "=": operator.eq, - "-": operator.sub, - "!=": operator.ne, - "<": operator.lt, - ">": operator.gt, - "<=": operator.le, - "=<": operator.le, - ">=": operator.ge, - "=>": operator.ge, - "/": operator.truediv, - "*": operator.mul, - "in": func_in, - "not in": func_not_in, - "like": like, - "not like": not_like, - "regex": func_regex, - "between": func_between, - "is": func_is, - "timespan": func_timespan, - "nested_set": NestedSetHierarchy, - # TODO: Add support for custom operators (WIP) - via filters_config hooks -} - - class Engine: - tables: dict[str, str] = {} - def get_query( self, table: str, fields: list | tuple | None = None, filters: dict[str, str | int] | str | int | list[list | str | int] | None = None, - pluck: str | None = None, order_by: str | None = None, group_by: str | None = None, limit: int | None = None, @@ -218,15 +40,11 @@ class Engine: update: bool = False, into: bool = False, delete: bool = False, - ) -> MySQLQueryBuilder | PostgreSQLQueryBuilder: - # Clean up state before each query + ) -> QueryBuilder: self.is_mariadb = frappe.db.db_type == "mariadb" self.is_postgres = frappe.db.db_type == "postgres" - self.tables = {} - self.implicit_joins = set() - self.doctype = table - self.table = self.get_table(table) + self.table = frappe.qb.DocType(table) if update: self.query = frappe.qb.update(self.table) @@ -236,19 +54,9 @@ class Engine: self.query = frappe.qb.from_(self.table).delete() else: self.query = frappe.qb.from_(self.table) - # add fields - self.fields = self.parse_fields(fields) - if not self.fields: - self.fields = [getattr(self.table, pluck or "name")] - - for field in self.fields: - if isinstance(field, DynamicTableField): - self.query = field.apply_select(self.query) - else: - self.query = self.query.select(field) + self.apply_fields(fields) self.apply_filters(filters) - self.apply_implicit_joins() self.apply_order_by(order_by) if limit: @@ -268,16 +76,21 @@ class Engine: return self.query - def get_table(self, table_name: str | Table) -> Table: - if isinstance(table_name, Table): - return table_name - table_name = table_name.strip('"').strip("'") - if table_name not in self.tables: - self.tables[table_name] = frappe.qb.DocType(table_name) - return self.tables[table_name] + def apply_fields(self, fields): + # add fields + self.fields = self.parse_fields(fields) + if not self.fields: + self.fields = [getattr(self.table, "name")] + + for field in self.fields: + if isinstance(field, DynamicTableField): + self.query = field.apply_select(self.query) + else: + self.query = self.query.select(field) def apply_filters( - self, filters: dict[str, str | int | list] | str | int | list[list] | None = None + self, + filters: dict[str, str | int] | str | int | list[list | str | int] | None = None, ): if not filters: return @@ -332,12 +145,12 @@ class Engine: elif not doctype or doctype == self.doctype: _field = self.table[field] elif doctype: - _field = self.get_table(doctype)[field] + _field = frappe.qb.DocType(doctype)[field] # apply implicit join if child table is referenced if doctype and doctype != self.doctype: meta = frappe.get_meta(doctype) - table = self.get_table(doctype) + table = frappe.qb.DocType(doctype) if meta.istable and not self.query.is_joined(table): self.query = self.query.left_join(table).on( (table.parent == self.table.name) & (table.parenttype == self.doctype) @@ -354,14 +167,14 @@ class Engine: _value = self.get_function_object(_value) # Nested set - if _operator in self.OPERATOR_MAP["nested_set"]: + if _operator in OPERATOR_MAP["nested_set"]: hierarchy = _operator docname = _value result = get_nested_set_hierarchy_result(self.doctype, docname, hierarchy) operator_fn = ( - self.OPERATOR_MAP["not in"] + OPERATOR_MAP["not in"] if hierarchy in ("not ancestors of", "not descendants of") - else self.OPERATOR_MAP["in"] + else OPERATOR_MAP["in"] ) if result: result = list(itertools.chain.from_iterable(result)) @@ -370,7 +183,7 @@ class Engine: self.query = self.query.where(operator_fn(_field, ("",))) return - operator_fn = self.OPERATOR_MAP[_operator.casefold()] + operator_fn = OPERATOR_MAP[_operator.casefold()] if _value is None and isinstance(_field, Field): self.query = self.query.where(_field.isnull()) else: @@ -490,15 +303,6 @@ class Engine: return _fields - def apply_implicit_joins(self): - for d in self.implicit_joins: - doctype, join_type = d - table = self.get_table(doctype) - if join_type == "child": - self.query = self.query.left_join(table).on( - (table.parent == self.table.name) & (table.parenttype == self.doctype) - ) - def apply_order_by(self, order_by: str | None): if not order_by or order_by == "KEEP_DEFAULT_ORDERING": return @@ -509,22 +313,6 @@ class Engine: order_direction = Order.asc if order_direction.lower() == "asc" else Order.desc self.query = self.query.orderby(order_field, order=order_direction) - @cached_property - def OPERATOR_MAP(self): - # default operators - all_operators = OPERATOR_MAP.copy() - - # TODO: update with site-specific custom operators / removed previous buggy implementation - if frappe.get_hooks("filters_config"): - from frappe.utils.commands import warn - - warn( - "The 'filters_config' hook used to add custom operators is not yet implemented" - " in frappe.db.query engine. Use db_query (frappe.get_list) instead." - ) - - return all_operators - class Permission: @classmethod @@ -657,3 +445,46 @@ class LinkTableField(DynamicTableField): if not query.is_joined(table): query = query.left_join(table).on(table.name == getattr(main_table, self.link_fieldname)) return query + + +def literal_eval_(literal): + try: + return literal_eval(literal) + except (ValueError, SyntaxError): + return literal + + +def has_function(field): + _field = field.casefold() if (isinstance(field, str) and "`" not in field) else field + if not issubclass(type(_field), Criterion): + if any([f"{func}(" in _field for func in SQL_FUNCTIONS]): + return True + + +def get_nested_set_hierarchy_result(doctype: str, name: str, hierarchy: str): + table = frappe.qb.DocType(doctype) + try: + lft, rgt = frappe.qb.from_(table).select("lft", "rgt").where(table.name == name).run()[0] + except IndexError: + lft, rgt = None, None + + if hierarchy in ("descendants of", "not descendants of"): + result = ( + frappe.qb.from_(table) + .select(table.name) + .where(table.lft > lft) + .where(table.rgt < rgt) + .orderby(table.lft, order=Order.asc) + .run() + ) + else: + # Get ancestor elements of a DocType with a tree structure + result = ( + frappe.qb.from_(table) + .select(table.name) + .where(table.lft < lft) + .where(table.rgt > rgt) + .orderby(table.lft, order=Order.desc) + .run() + ) + return result diff --git a/frappe/query_builder/utils.py b/frappe/query_builder/utils.py index 004a226036..bfc2c49b8e 100644 --- a/frappe/query_builder/utils.py +++ b/frappe/query_builder/utils.py @@ -2,9 +2,7 @@ from enum import Enum from importlib import import_module from typing import Any, Callable, get_type_hints -from pypika import Query -from pypika.dialects import MySQLQueryBuilder, PostgreSQLQueryBuilder -from pypika.queries import Column +from pypika.queries import Column, QueryBuilder from pypika.terms import PseudoColumn import frappe @@ -56,7 +54,7 @@ def get_query_builder(type_of_db: str) -> Postgres | MariaDB: return picks[db] -def get_query(*args, **kwargs) -> MySQLQueryBuilder | PostgreSQLQueryBuilder: +def get_query(*args, **kwargs) -> QueryBuilder: from frappe.database.query import Engine return Engine().get_query(*args, **kwargs) From 95d8a0f919c68f5f828256b4957045bb786e05de Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Tue, 10 Jan 2023 16:48:38 +0530 Subject: [PATCH 039/407] fix: allow Table instance --- frappe/database/query.py | 14 ++++++++++---- frappe/database/utils.py | 8 ++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index 4852f80739..cdca28601d 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -5,11 +5,12 @@ from types import BuiltinFunctionType from typing import TYPE_CHECKING import sqlparse -from pypika.queries import QueryBuilder +from pypika.queries import QueryBuilder, Table import frappe from frappe import _ from frappe.database.operator_map import OPERATOR_MAP +from frappe.database.utils import get_doctype_name from frappe.query_builder import Criterion, Field, Order, functions from frappe.query_builder.functions import Function, SqlFunctions from frappe.query_builder.utils import PseudoColumnMapper @@ -28,7 +29,7 @@ COMMA_PATTERN = re.compile(r",\s*(?![^()]*\))") class Engine: def get_query( self, - table: str, + table: str | Table, fields: list | tuple | None = None, filters: dict[str, str | int] | str | int | list[list | str | int] | None = None, order_by: str | None = None, @@ -43,8 +44,13 @@ class Engine: ) -> QueryBuilder: self.is_mariadb = frappe.db.db_type == "mariadb" self.is_postgres = frappe.db.db_type == "postgres" - self.doctype = table - self.table = frappe.qb.DocType(table) + + if isinstance(table, Table): + self.table = table + self.doctype = get_doctype_name(table.get_sql()) + else: + self.doctype = table + self.table = frappe.qb.DocType(table) if update: self.query = frappe.qb.update(self.table) diff --git a/frappe/database/utils.py b/frappe/database/utils.py index 4ea039e5a7..f89ba3c737 100644 --- a/frappe/database/utils.py +++ b/frappe/database/utils.py @@ -34,6 +34,14 @@ def is_pypika_function_object(field: str) -> bool: return getattr(field, "__module__", None) == "pypika.functions" or isinstance(field, Function) +def get_doctype_name(table_name: str) -> str: + if "tab" in table_name: + table_name = table_name.replace("tab", "") + table_name = table_name.replace("`", "") + table_name = table_name.replace('"', "") + return table_name + + class LazyString: def _setup(self) -> None: raise NotImplementedError From 3ee510439bb2e5ae585e9827632eb4ed0ccd0d05 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Jan 2023 17:43:14 +0530 Subject: [PATCH 040/407] fix(db_query): Allow standalone functions, rename get_permitted_fields --- frappe/model/db_query.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 425a0ade6c..cdb934eb03 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -561,7 +561,7 @@ class DatabaseQuery: if self.flags.ignore_permissions: return - available_fields = get_available_fields(doctype=self.doctype) + available_fields = get_permitted_fields(doctype=self.doctype) for i, field in enumerate(self.fields): column = field.split(" ", 1)[0].replace("`", "") @@ -580,7 +580,7 @@ class DatabaseQuery: if table in self.tables: ch_doctype = table.replace("`", "").replace("tab", "", 1) - available_child_table_fields = get_available_fields( + available_child_table_fields = get_permitted_fields( doctype=ch_doctype, parenttype=self.doctype ) if column in available_child_table_fields: @@ -597,7 +597,9 @@ class DatabaseQuery: elif _params := FN_PARAMS_PATTERN.findall(column): params = (x for x in _params[0].split(",")) for param in params: - if param in available_fields or param.isnumeric() or "'" in param or '"' in param: + if ( + not param or param in available_fields or param.isnumeric() or "'" in param or '"' in param + ): continue else: self.fields.remove(field) @@ -1202,7 +1204,7 @@ def requires_owner_constraint(role_permissions): return True -def get_available_fields(doctype, parenttype=None): +def get_permitted_fields(doctype, parenttype=None): meta = frappe.get_meta(doctype) if doctype in core_doctypes_list: From d2ad86d2fe897ad83ffd1e13f3979de1ceadaf08 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 10 Jan 2023 17:44:06 +0530 Subject: [PATCH 041/407] test: Add tests for permlevel handing in get_list --- frappe/tests/test_db_query.py | 174 +++++++++++++++++++++++++--------- 1 file changed, 127 insertions(+), 47 deletions(-) diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 1964792ea8..4821e11665 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -1,6 +1,7 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import datetime +from contextlib import contextmanager import frappe from frappe.core.page.permission_manager.permission_manager import add, reset, update @@ -16,6 +17,32 @@ from frappe.utils.testutils import add_custom_field, clear_custom_fields test_dependencies = ["User", "Blog Post", "Blog Category", "Blogger"] +@contextmanager +def setup_test_user(set_user=False): + test_user = frappe.get_doc("User", "test@example.com") + user_roles = frappe.get_roles() + test_user.remove_roles(*user_roles) + test_user.add_roles("Blogger") + + if set_user: + frappe.set_user(test_user.name) + + yield test_user + + test_user.remove_roles("Blogger") + test_user.add_roles(*user_roles) + + +@contextmanager +def setup_patched_blog_post(): + add_child_table_to_blog_post() + make_property_setter("Blog Post", "published", "permlevel", 1, "Int") + reset("Blog Post") + add("Blog Post", "Website Manager", 1) + update("Blog Post", "Website Manager", 1, "write", 1) + yield + + class TestReportview(FrappeTestCase): def setUp(self): frappe.set_user("Administrator") @@ -728,67 +755,120 @@ class TestReportview(FrappeTestCase): self.assertEqual(users_unedited[0].modified, users_unedited[0].creation) self.assertNotEqual(users_edited[0].modified, users_edited[0].creation) - def test_reportview_get(self): - user = frappe.get_doc("User", "test@example.com") - add_child_table_to_blog_post() + def test_permlevel_fields(self): + with setup_patched_blog_post(), setup_test_user(set_user=True): + data = frappe.get_list( + "Blog Post", filters={"published": 1}, fields=["name", "published"], limit=1 + ) + self.assertFalse("published" in data[0]) + self.assertTrue("name" in data[0]) + self.assertEqual(len(data[0]), 1) - user_roles = frappe.get_roles() - user.remove_roles(*user_roles) - user.add_roles("Blogger") + data = frappe.get_list( + "Blog Post", filters={"published": 1}, fields=["name", "`published`"], limit=1 + ) + self.assertFalse("published" in data[0]) + self.assertTrue("name" in data[0]) + self.assertEqual(len(data[0]), 1) - make_property_setter("Blog Post", "published", "permlevel", 1, "Int") - reset("Blog Post") - add("Blog Post", "Website Manager", 1) - update("Blog Post", "Website Manager", 1, "write", 1) + data = frappe.get_list( + "Blog Post", filters={"published": 1}, fields=["name", "`tabBlog Post`.`published`"], limit=1 + ) + self.assertFalse("published" in data[0]) + self.assertTrue("name" in data[0]) + self.assertEqual(len(data[0]), 1) - frappe.set_user(user.name) + data = frappe.get_list( + "Blog Post", filters={"published": 1}, fields=["name", "`tabTest Child`.`test_field`"], limit=1 + ) + self.assertFalse("test_field" in data[0]) + self.assertTrue("name" in data[0]) + self.assertEqual(len(data[0]), 1) - frappe.local.request = frappe._dict() - frappe.local.request.method = "POST" + data = frappe.get_list( + "Blog Post", filters={"published": 1}, fields=["name", "MAX(`published`)"], limit=1 + ) + self.assertTrue("name" in data[0]) + self.assertEqual(len(data[0]), 1) - frappe.local.form_dict = frappe._dict( - { - "doctype": "Blog Post", - "fields": ["published", "title", "`tabTest Child`.`test_field`"], - } - ) + data = frappe.get_list( + "Blog Post", filters={"published": 1}, fields=["name", "LAST(published)"], limit=1 + ) + self.assertTrue("name" in data[0]) + self.assertEqual(len(data[0]), 1) - # even if * is passed, fields which are not accessible should be filtered out - response = execute_cmd("frappe.desk.reportview.get") - self.assertListEqual(response["keys"], ["title"]) - frappe.local.form_dict = frappe._dict( - { - "doctype": "Blog Post", - "fields": ["*"], - } - ) + data = frappe.get_list( + "Blog Post", filters={"published": 1}, fields=["name", "MAX(`modified`)"], limit=1 + ) + self.assertEqual(len(data[0]), 2) - response = execute_cmd("frappe.desk.reportview.get") - self.assertNotIn("published", response["keys"]) + data = frappe.get_list( + "Blog Post", filters={"published": 1}, fields=["name", "now() abhi"], limit=1 + ) + self.assertIsInstance(data[0]["abhi"], datetime.datetime) + self.assertEqual(len(data[0]), 2) - frappe.set_user("Administrator") - user.add_roles("Website Manager") - frappe.set_user(user.name) + data = frappe.get_list( + "Blog Post", filters={"published": 1}, fields=["name", "'LABEL'"], limit=1 + ) + self.assertTrue("name" in data[0]) + self.assertTrue("LABEL" in data[0]) + self.assertEqual(len(data[0]), 2) - frappe.set_user("Administrator") + data = frappe.get_list( + "Blog Post", filters={"published": 1}, fields=["name", "COUNT(*) as count"], limit=1 + ) + self.assertTrue("count" in data[0]) + self.assertEqual(len(data[0]), 2) + data = frappe.get_list( + "Blog Post", filters={"published": 1}, fields=["name", "COUNT(*) count"], limit=1 + ) + self.assertTrue("count" in data[0]) + self.assertEqual(len(data[0]), 2) + + def test_reportview_get_permlevel_system_users(self): + with setup_patched_blog_post(), setup_test_user(set_user=True): + frappe.local.request = frappe._dict() + frappe.local.request.method = "POST" + frappe.local.form_dict = frappe._dict( + { + "doctype": "Blog Post", + "fields": ["published", "title", "`tabTest Child`.`test_field`"], + } + ) + + # even if * is passed, fields which are not accessible should be filtered out + response = execute_cmd("frappe.desk.reportview.get") + self.assertListEqual(response["keys"], ["title"]) + frappe.local.form_dict = frappe._dict( + { + "doctype": "Blog Post", + "fields": ["*"], + } + ) + + response = execute_cmd("frappe.desk.reportview.get") + self.assertNotIn("published", response["keys"]) + + def test_reportview_get_admin(self): # Admin should be able to see access all fields - frappe.local.form_dict = frappe._dict( - { - "doctype": "Blog Post", - "fields": ["published", "title", "`tabTest Child`.`test_field`"], - } - ) - - response = execute_cmd("frappe.desk.reportview.get") - self.assertListEqual(response["keys"], ["published", "title", "test_field"]) - - # reset user roles - user.remove_roles("Blogger", "Website Manager") - user.add_roles(*user_roles) + with setup_patched_blog_post(): + frappe.local.request = frappe._dict() + frappe.local.request.method = "POST" + frappe.local.form_dict = frappe._dict( + { + "doctype": "Blog Post", + "fields": ["published", "title", "`tabTest Child`.`test_field`"], + } + ) + response = execute_cmd("frappe.desk.reportview.get") + self.assertListEqual(response["keys"], ["published", "title", "test_field"]) def test_reportview_get_aggregation(self): # test aggregation based on child table field + frappe.local.request = frappe._dict() + frappe.local.request.method = "POST" frappe.local.form_dict = frappe._dict( { "doctype": "DocType", From a0f6a5ff46068dd9b9665bb1bd2e16564b84a2ce Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Tue, 10 Jan 2023 18:21:50 +0530 Subject: [PATCH 042/407] fix: move pluck to run --- frappe/database/database.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index c104c9cf9c..2c0718a48a 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -835,10 +835,9 @@ class Database: fields=field, filters=names, order_by=order_by, - pluck=pluck, distinct=distinct, limit=limit, - ).run(debug=debug, run=run, as_dict=as_dict) + ).run(debug=debug, run=run, as_dict=as_dict, pluck=pluck) return {} def set_value( From 76deeb531cd6a8d022426658dd3e488dff0ce127 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Tue, 10 Jan 2023 18:22:05 +0530 Subject: [PATCH 043/407] fix: support list of str or int in filters --- frappe/database/query.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index cdca28601d..726f930b19 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -111,11 +111,14 @@ class Engine: self.apply_dict_filters(filters) elif isinstance(filters, (list, tuple)): - for filter in filters: - if isinstance(filter, (str, int, Criterion, dict)): - self.apply_filters(filter) - elif isinstance(filter, (list, tuple)): - self.apply_list_filters(filter) + if all(isinstance(d, (str, int)) for d in filters): + self.apply_dict_filters({"name": ("in", filters)}) + else: + for filter in filters: + if isinstance(filter, (str, int, Criterion, dict)): + self.apply_filters(filter) + elif isinstance(filter, (list, tuple)): + self.apply_list_filters(filter) def apply_list_filters(self, filter: list): if len(filter) == 2: From d1fb60edfb05f7c399a0f860a46b9e54354d8abc Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Jan 2023 11:11:27 +0530 Subject: [PATCH 044/407] test: Remove auto_repeat custom field in cleanup --- .../doctype/auto_repeat/test_auto_repeat.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py index 128bcc90cc..2754da879f 100644 --- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py @@ -1,5 +1,7 @@ # Copyright (c) 2018, Frappe Technologies and Contributors # License: MIT. See LICENSE +from typing import TYPE_CHECKING + import frappe from frappe.automation.doctype.auto_repeat.auto_repeat import ( create_repeated_entries, @@ -10,8 +12,11 @@ from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, add_months, getdate, today +if TYPE_CHECKING: + from frappe.custom.doctype.custom_field.custom_field import CustomField -def add_custom_fields(): + +def add_custom_fields() -> "CustomField": df = dict( fieldname="auto_repeat", label="Auto Repeat", @@ -22,15 +27,17 @@ def add_custom_fields(): print_hide=1, read_only=1, ) - create_custom_field("ToDo", df) + return create_custom_field("ToDo", df) or frappe.get_doc( + "Custom Field", dict(fieldname=df["fieldname"], dt="ToDo") + ) class TestAutoRepeat(FrappeTestCase): - def setUp(self): - if not frappe.db.sql( - "SELECT `fieldname` FROM `tabCustom Field` WHERE `fieldname`='auto_repeat' and `dt`=%s", "Todo" - ): - add_custom_fields() + @classmethod + def setUpClass(cls): + cls.custom_field = add_custom_fields() + cls.addClassCleanup(cls.custom_field.delete) + return super().setUpClass() def test_daily_auto_repeat(self): todo = frappe.get_doc( From 9012b95a9a6b287ab1003745f152971d27347d93 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Jan 2023 12:42:45 +0530 Subject: [PATCH 045/407] fix(postgres): group_by in reportview, tests Add approporiate group_by, order_by clauses in reportview and tests for featureset compatibility with postgres --- frappe/desk/reportview.py | 12 ++++++------ frappe/tests/test_db_query.py | 23 +++++++++++++++++++---- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index 8f929311e0..fcecdf94b0 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -150,13 +150,13 @@ def setup_group_by(data): frappe.throw(_("Invalid aggregate function")) if frappe.db.has_column(data.aggregate_on_doctype, data.aggregate_on_field): - data.fields.append( - "{aggregate_function}(`tab{aggregate_on_doctype}`.`{aggregate_on_field}`) AS _aggregate_column".format( - **data - ) - ) + column = f"`tab{data.aggregate_on_doctype}`.`{data.aggregate_on_field}`" + aggregate_function = data.aggregate_function + + data.fields.append(f"{aggregate_function}({column}) AS _aggregate_column") if data.aggregate_on_field: - data.fields.append(f"`tab{data.aggregate_on_doctype}`.`{data.aggregate_on_field}`") + data.fields.append(column) + data.group_by += f", {column}" else: raise_invalid_field(data.aggregate_on_field) diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 4821e11665..ce4b4b6186 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -798,7 +798,12 @@ class TestReportview(FrappeTestCase): self.assertEqual(len(data[0]), 1) data = frappe.get_list( - "Blog Post", filters={"published": 1}, fields=["name", "MAX(`modified`)"], limit=1 + "Blog Post", + filters={"published": 1}, + fields=["name", "MAX(`modified`)"], + limit=1, + order_by=None, + group_by="name", ) self.assertEqual(len(data[0]), 2) @@ -812,17 +817,27 @@ class TestReportview(FrappeTestCase): "Blog Post", filters={"published": 1}, fields=["name", "'LABEL'"], limit=1 ) self.assertTrue("name" in data[0]) - self.assertTrue("LABEL" in data[0]) + self.assertTrue("LABEL" in data[0].values()) self.assertEqual(len(data[0]), 2) data = frappe.get_list( - "Blog Post", filters={"published": 1}, fields=["name", "COUNT(*) as count"], limit=1 + "Blog Post", + filters={"published": 1}, + fields=["name", "COUNT(*) as count"], + limit=1, + order_by=None, + group_by="name", ) self.assertTrue("count" in data[0]) self.assertEqual(len(data[0]), 2) data = frappe.get_list( - "Blog Post", filters={"published": 1}, fields=["name", "COUNT(*) count"], limit=1 + "Blog Post", + filters={"published": 1}, + fields=["name", "COUNT(*) count"], + limit=1, + order_by=None, + group_by="name", ) self.assertTrue("count" in data[0]) self.assertEqual(len(data[0]), 2) From 19b728f51457cf29c7971c0d74b8dd9436ad0e05 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 11 Jan 2023 14:05:10 +0530 Subject: [PATCH 046/407] fix(db_query): Parse distinct field usages --- frappe/model/db_query.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index cdb934eb03..a5a6dcd91c 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -564,7 +564,11 @@ class DatabaseQuery: available_fields = get_permitted_fields(doctype=self.doctype) for i, field in enumerate(self.fields): - column = field.split(" ", 1)[0].replace("`", "") + if "distinct" in field: + self.distinct = True + column = field.split(" ", 2)[1].replace("`", "") + else: + column = field.split(" ", 1)[0].replace("`", "") if column == "*": self.fields[i : i + 1] = available_fields From 4e8bbd6c93daf3bc9458382050861e2249cbaf6b Mon Sep 17 00:00:00 2001 From: Aradhya Date: Fri, 2 Dec 2022 12:47:17 +0530 Subject: [PATCH 047/407] refactor: allowing unlocking of doc when job id is not set --- .../doctype/submission_queue/submission_queue.js | 2 +- .../doctype/submission_queue/submission_queue.py | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/frappe/core/doctype/submission_queue/submission_queue.js b/frappe/core/doctype/submission_queue/submission_queue.js index 93d6b981dc..414c8c9ee0 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.js +++ b/frappe/core/doctype/submission_queue/submission_queue.js @@ -3,7 +3,7 @@ frappe.ui.form.on("Submission Queue", { refresh: function (frm) { - if (frm.doc.status === "Queued" && frm.doc.job_id) { + if (frm.doc.status === "Queued") { frm.add_custom_button(__("Unlock Reference Document"), () => { frappe.confirm(__("Are you sure you want to go ahead with this action?"), () => { frm.call("unlock_doc"); diff --git a/frappe/core/doctype/submission_queue/submission_queue.py b/frappe/core/doctype/submission_queue/submission_queue.py index 2bb4200a87..caa0352c97 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.py +++ b/frappe/core/doctype/submission_queue/submission_queue.py @@ -69,6 +69,10 @@ class SubmissionQueue(Document): def background_submission(self, to_be_queued_doc: Document, action_for_queuing: str): # Set the job id for that submission doctype + submission_doc = frappe.get_doc(self.doctype, self.name) + if submission_doc.state == "Failed": + # If the document has already been unlocked by _unlock_reference_doc_unlock_reference_doc + return self.update_job_id(get_current_job().id) _action = action_for_queuing.lower() if _action == "update": @@ -129,9 +133,10 @@ class SubmissionQueue(Document): enqueue_create_notification([notify_to], notification_doc) def _unlock_reference_doc(self): - """ - Only execute if self.job_id is defined. - """ + if not self.job_id: + self.queued_doc.unlock() + frappe.db.set_value(self.doctype, self.name, {"status": "Failed"}) + try: job = Job.fetch(self.job_id, connection=get_redis_conn()) status = job.get_status(refresh=True) @@ -156,8 +161,7 @@ class SubmissionQueue(Document): # NOTE: this can lead to some weird unlocking/locking behaviours. # for example: hitting unlock on a submission could lead to unlocking of another submission # of the same reference document. - - if self.status != "Queued" and not self.job_id: + if self.status != "Queued": return self._unlock_reference_doc() From 1b46b0e34768540de855457e9f42331170c755c4 Mon Sep 17 00:00:00 2001 From: Aradhya Date: Fri, 2 Dec 2022 13:54:25 +0530 Subject: [PATCH 048/407] fix: fixed status fetch and refactored message --- .../submission_queue/submission_queue.py | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/frappe/core/doctype/submission_queue/submission_queue.py b/frappe/core/doctype/submission_queue/submission_queue.py index caa0352c97..3e30ef1ef0 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.py +++ b/frappe/core/doctype/submission_queue/submission_queue.py @@ -68,11 +68,10 @@ class SubmissionQueue(Document): ) def background_submission(self, to_be_queued_doc: Document, action_for_queuing: str): - # Set the job id for that submission doctype - submission_doc = frappe.get_doc(self.doctype, self.name) - if submission_doc.state == "Failed": - # If the document has already been unlocked by _unlock_reference_doc_unlock_reference_doc + if self.status == "Failed": + # If the document has already been unlocked by _unlock_reference_doc return + # Set the job id for that submission doctyp self.update_job_id(get_current_job().id) _action = action_for_queuing.lower() if _action == "update": @@ -97,17 +96,20 @@ class SubmissionQueue(Document): self.notify(values["status"], action_for_queuing) def notify(self, submission_status: str, action: str): + message = _("Action {0} run on {1} {2} ") if submission_status == "Failed": doctype = self.doctype docname = self.name - message = _("Submission of {0} {1} with action {2} failed") + message += "failed" else: doctype = self.ref_doctype docname = self.ref_docname - message = _("Submission of {0} {1} with action {2} completed successfully") + message += "finished" message = message.format( - frappe.bold(str(self.ref_doctype)), frappe.bold(self.ref_docname), frappe.bold(action) + frappe.bold(action), + frappe.bold(str(self.ref_doctype)), + frappe.bold(self.ref_docname), ) time_diff = time_diff_in_seconds(now(), self.created_at) if cint(time_diff) <= 60: @@ -133,10 +135,6 @@ class SubmissionQueue(Document): enqueue_create_notification([notify_to], notification_doc) def _unlock_reference_doc(self): - if not self.job_id: - self.queued_doc.unlock() - frappe.db.set_value(self.doctype, self.name, {"status": "Failed"}) - try: job = Job.fetch(self.job_id, connection=get_redis_conn()) status = job.get_status(refresh=True) @@ -169,7 +167,7 @@ class SubmissionQueue(Document): def queue_submission(doc: Document, action: str, alert: bool = True): queue = frappe.new_doc("Submission Queue") - queue.state = "Queued" + queue.status = "Queued" queue.ref_doctype = doc.doctype queue.ref_docname = doc.name queue.insert(doc, action) From e00d89f430206d338f770c464cd30ff0ab5f4d52 Mon Sep 17 00:00:00 2001 From: Aradhya Date: Fri, 2 Dec 2022 15:06:58 +0530 Subject: [PATCH 049/407] feat: Added queue_submission to workflows --- frappe/model/workflow.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index 8338157996..e7835fba8d 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -101,6 +101,9 @@ def is_transition_condition_satisfied(transition, doc) -> bool: @frappe.whitelist() def apply_workflow(doc, action): """Allow workflow action on the current doc""" + from frappe.core.doctype.submission_queue.submission_queue import queue_submission + from frappe.utils.scheduler import is_scheduler_inactive + doc = frappe.get_doc(frappe.parse_json(doc)) workflow = get_workflow(doc.doctype) transitions = get_transitions(doc, workflow) @@ -132,7 +135,10 @@ def apply_workflow(doc, action): if doc.docstatus.is_draft() and new_docstatus == DocStatus.draft(): doc.save() elif doc.docstatus.is_draft() and new_docstatus == DocStatus.submitted(): - doc.submit() + if doc.meta.queue_in_background and not is_scheduler_inactive(): + queue_submission(doc, action="submit") + else: + doc.submit() elif doc.docstatus.is_submitted() and new_docstatus == DocStatus.submitted(): doc.save() elif doc.docstatus.is_submitted() and new_docstatus == DocStatus.cancelled(): From cffcb0fa176eefbfb4bcc754b0621043255646f3 Mon Sep 17 00:00:00 2001 From: Aradhya Date: Sat, 3 Dec 2022 23:40:57 +0530 Subject: [PATCH 050/407] refactor: failed attempts banner --- .../submission_queue/submission_queue.py | 28 ++++++++++++++++--- frappe/public/js/frappe/form/form.js | 10 +++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/frappe/core/doctype/submission_queue/submission_queue.py b/frappe/core/doctype/submission_queue/submission_queue.py index 3e30ef1ef0..181f5f21cb 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.py +++ b/frappe/core/doctype/submission_queue/submission_queue.py @@ -182,14 +182,34 @@ def queue_submission(doc: Document, action: str, alert: bool = True): ) +def format_tb(traceback: str): + traceback = traceback.strip().split("\n")[-1] + if len(traceback.split()) > 6: + return " ".join(traceback.split()[0:6]) + "..." + return traceback + + @frappe.whitelist() def get_latest_submissions(doctype, docname): # NOTE: not used creation as orderby intentianlly as we have used update_modified=False everywhere # hence assuming modified will be equal to creation for submission queue documents dt = "Submission Queue" + out = {} + filters = {"ref_doctype": doctype, "ref_docname": docname} - return { - "latest_submission": frappe.db.get_value(dt, filters), - "latest_failed_submission": frappe.db.get_value(dt, filters | {"status": "Failed"}), - } + failed_submission = frappe.db.get_value( + dt, filters=filters | {"status": "Failed"}, fieldname=["name", "exception"] + ) + latest_submission = frappe.db.get_value(dt, filters=filters, fieldname=["name", "status"]) + + if failed_submission: + out["latest_failed_submission"], out["latest_failed_submission_exc_info"] = ( + failed_submission[0], + format_tb(failed_submission[1]), + ) + + if latest_submission: + out["latest_submission"], out["latest_submission_status"] = latest_submission + + return out diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 75a1def1dc..0f9ff22dee 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -2081,18 +2081,22 @@ frappe.ui.form.Form = class FrappeForm { col_width = 3; failed_link = ``; } else { - submission_label = __("Previous Falied Submission"); + if (r.message.latest_failed_submission_exc_info) { + submission_label = r.message.latest_failed_submission_exc_info; + } else { + submission_label = "Errored"; + } } } let html = `
- ${__("Submission Status:")} + ${__(`Submission Status: ${r.message.latest_submission_status}`)}
${submission_label} From 12f0be1906d2cd6d84c9d3759a1909cbed65c178 Mon Sep 17 00:00:00 2001 From: phot0n Date: Tue, 3 Jan 2023 00:41:37 +0530 Subject: [PATCH 051/407] refactor(minor): better banner and removed unnecessary complexity for unlocking ref document --- .../submission_queue/submission_queue.json | 5 +- .../submission_queue/submission_queue.py | 79 +++++++------------ frappe/public/js/frappe/form/form.js | 69 ++++++---------- frappe/public/scss/common/css_variables.scss | 2 +- 4 files changed, 57 insertions(+), 98 deletions(-) diff --git a/frappe/core/doctype/submission_queue/submission_queue.json b/frappe/core/doctype/submission_queue/submission_queue.json index d1f66ffa13..ce28007e23 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.json +++ b/frappe/core/doctype/submission_queue/submission_queue.json @@ -20,8 +20,9 @@ "fields": [ { "fieldname": "job_id", - "fieldtype": "Data", + "fieldtype": "Link", "label": "Job Id", + "options": "RQ Job", "read_only": 1 }, { @@ -87,7 +88,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2022-11-12 16:48:37.797232", + "modified": "2023-01-02 23:53:55.010001", "modified_by": "Administrator", "module": "Core", "name": "Submission Queue", diff --git a/frappe/core/doctype/submission_queue/submission_queue.py b/frappe/core/doctype/submission_queue/submission_queue.py index 181f5f21cb..b1e20516a8 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.py +++ b/frappe/core/doctype/submission_queue/submission_queue.py @@ -13,7 +13,6 @@ from frappe.desk.doctype.notification_log.notification_log import enqueue_create from frappe.model.document import Document from frappe.monitor import add_data_to_monitor from frappe.utils import now, time_diff_in_seconds -from frappe.utils.background_jobs import get_redis_conn from frappe.utils.data import cint @@ -39,6 +38,7 @@ class SubmissionQueue(Document): frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days)))) def insert(self, to_be_queued_doc: Document, action: str): + self.status = "Queued" self.to_be_queued_doc = to_be_queued_doc self.action_for_queuing = action super().insert(ignore_permissions=True) @@ -68,11 +68,9 @@ class SubmissionQueue(Document): ) def background_submission(self, to_be_queued_doc: Document, action_for_queuing: str): - if self.status == "Failed": - # If the document has already been unlocked by _unlock_reference_doc - return # Set the job id for that submission doctyp self.update_job_id(get_current_job().id) + _action = action_for_queuing.lower() if _action == "update": _action = "submit" @@ -96,21 +94,21 @@ class SubmissionQueue(Document): self.notify(values["status"], action_for_queuing) def notify(self, submission_status: str, action: str): - message = _("Action {0} run on {1} {2} ") if submission_status == "Failed": doctype = self.doctype docname = self.name - message += "failed" + message = _("Submission of {0} {1} with action {2} failed") else: doctype = self.ref_doctype docname = self.ref_docname - message += "finished" + message = _("Submission of {0} {1} with action {2} completed successfully") message = message.format( - frappe.bold(action), frappe.bold(str(self.ref_doctype)), - frappe.bold(self.ref_docname), + frappe.bold(str(self.ref_docname)), + frappe.bold(action), ) + time_diff = time_diff_in_seconds(now(), self.created_at) if cint(time_diff) <= 60: frappe.publish_realtime( @@ -134,40 +132,21 @@ class SubmissionQueue(Document): notify_to = frappe.db.get_value("User", self.enqueued_by, fieldname="email") enqueue_create_notification([notify_to], notification_doc) - def _unlock_reference_doc(self): - try: - job = Job.fetch(self.job_id, connection=get_redis_conn()) - status = job.get_status(refresh=True) - exc = job.exc_info - except NoSuchJobError: - exc = None - status = "failed" - - if status in ("queued", "started"): - frappe.msgprint(_("Document in queue for execution!")) - return - - self.queued_doc.unlock() - values = ( - {"status": "Finished"} if status == "finished" else {"status": "Failed", "exception": exc} - ) - frappe.db.set_value(self.doctype, self.name, values, update_modified=False) - frappe.msgprint(_("Document Unlocked")) - @frappe.whitelist() def unlock_doc(self): # NOTE: this can lead to some weird unlocking/locking behaviours. # for example: hitting unlock on a submission could lead to unlocking of another submission # of the same reference document. + if self.status != "Queued": return - self._unlock_reference_doc() + self.queued_doc.unlock() + frappe.msgprint(_("Document Unlocked")) def queue_submission(doc: Document, action: str, alert: bool = True): queue = frappe.new_doc("Submission Queue") - queue.status = "Queued" queue.ref_doctype = doc.doctype queue.ref_docname = doc.name queue.insert(doc, action) @@ -182,34 +161,30 @@ def queue_submission(doc: Document, action: str, alert: bool = True): ) -def format_tb(traceback: str): - traceback = traceback.strip().split("\n")[-1] - if len(traceback.split()) > 6: - return " ".join(traceback.split()[0:6]) + "..." - return traceback - - @frappe.whitelist() def get_latest_submissions(doctype, docname): # NOTE: not used creation as orderby intentianlly as we have used update_modified=False everywhere # hence assuming modified will be equal to creation for submission queue documents - dt = "Submission Queue" - out = {} - - filters = {"ref_doctype": doctype, "ref_docname": docname} - failed_submission = frappe.db.get_value( - dt, filters=filters | {"status": "Failed"}, fieldname=["name", "exception"] + latest_submission = frappe.db.get_value( + "Submission Queue", + filters={"ref_doctype": doctype, "ref_docname": docname}, + fieldname=["name", "exception", "status"], ) - latest_submission = frappe.db.get_value(dt, filters=filters, fieldname=["name", "status"]) - - if failed_submission: - out["latest_failed_submission"], out["latest_failed_submission_exc_info"] = ( - failed_submission[0], - format_tb(failed_submission[1]), - ) + out = None if latest_submission: - out["latest_submission"], out["latest_submission_status"] = latest_submission + out = { + "latest_submission": latest_submission[0], + "exc": format_tb(latest_submission[1]), + "status": latest_submission[2], + } return out + + +def format_tb(traceback: str | None = None): + if not traceback: + return + + return traceback.strip().split("\n")[-1] diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 0f9ff22dee..25b53638fe 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -2051,16 +2051,12 @@ frappe.ui.form.Form = class FrappeForm { this.doc.docstatus === 0 ) ) { - if (wrapper.length) { - wrapper.hide(); - wrapper.html(""); - } - + wrapper.length && wrapper.remove(); return; } if (!wrapper.length) { - wrapper = $('
'); + wrapper = $('
'); this.layout.wrapper.prepend(wrapper); } @@ -2070,53 +2066,40 @@ frappe.ui.form.Form = class FrappeForm { args: { doctype: this.doctype, docname: this.docname }, }) .then((r) => { - if (r.message.latest_submission) { + if (r.message?.latest_submission) { // if we are here that means some submission(s) were queued and are in queued/failed state - let col_width = 4; - let failed_link = ""; let submission_label = __("Previous Submission"); + let secondary = ""; + let div_class = "col-md-12"; - if (r.message.latest_failed_submission) { - if (r.message.latest_failed_submission !== r.message.latest_submission) { - col_width = 3; - failed_link = ``; - } else { - if (r.message.latest_failed_submission_exc_info) { - submission_label = r.message.latest_failed_submission_exc_info; - } else { - submission_label = "Errored"; - } - } + if (r.message.exc) { + secondary = `: ${r.message.exc}`; + } else { + div_class = "col-md-6"; + secondary = ` +
+
+ ${__( + "All Submissions" + )} + `; } let html = ` -
-
- ${__(`Submission Status: ${r.message.latest_submission_status}`)} + - - ${failed_link} - -
- `; + `; - wrapper.show(); + wrapper.removeClass("red").removeClass("yellow"); + wrapper.addClass(r.message.status == "Failed" ? "red" : "yellow"); wrapper.html(html); } else { - wrapper.hide(); - wrapper.html(""); + wrapper.remove(); } }); } diff --git a/frappe/public/scss/common/css_variables.scss b/frappe/public/scss/common/css_variables.scss index 1914e7479b..cfbcc001b6 100644 --- a/frappe/public/scss/common/css_variables.scss +++ b/frappe/public/scss/common/css_variables.scss @@ -163,7 +163,7 @@ $input-height: 28px !default; --bg-green: var(--dark-green-50); --bg-yellow: var(--yellow-50); --bg-orange: var(--orange-50); - --bg-red: var(--red-50); + --bg-red: var(--red-100); --bg-gray: var(--gray-200); --bg-light-gray: var(--gray-100); --bg-dark-gray: var(--gray-900); From f6489a6de861317f6ae4ac154814317eb528de14 Mon Sep 17 00:00:00 2001 From: phot0n Date: Tue, 3 Jan 2023 21:06:45 +0530 Subject: [PATCH 052/407] fix: allow submission queue doc reads from users if theyre owners * only show unlock doc button to system managers --- frappe/core/doctype/submission_queue/submission_queue.js | 2 +- frappe/core/doctype/submission_queue/submission_queue.json | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/submission_queue/submission_queue.js b/frappe/core/doctype/submission_queue/submission_queue.js index 414c8c9ee0..fc1e83ac49 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.js +++ b/frappe/core/doctype/submission_queue/submission_queue.js @@ -3,7 +3,7 @@ frappe.ui.form.on("Submission Queue", { refresh: function (frm) { - if (frm.doc.status === "Queued") { + if (frm.doc.status === "Queued" && frappe.boot.user.roles.includes("System Manager")) { frm.add_custom_button(__("Unlock Reference Document"), () => { frappe.confirm(__("Are you sure you want to go ahead with this action?"), () => { frm.call("unlock_doc"); diff --git a/frappe/core/doctype/submission_queue/submission_queue.json b/frappe/core/doctype/submission_queue/submission_queue.json index ce28007e23..4058276319 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.json +++ b/frappe/core/doctype/submission_queue/submission_queue.json @@ -88,7 +88,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-01-02 23:53:55.010001", + "modified": "2023-01-03 20:54:40.904584", "modified_by": "Administrator", "module": "Core", "name": "Submission Queue", @@ -103,6 +103,11 @@ "report": 1, "role": "System Manager", "share": 1 + }, + { + "if_owner": 1, + "read": 1, + "role": "All" } ], "sort_field": "modified", From 381ae564aa7af4f4b8aa436c8f2f68ce7de6a1bf Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Fri, 13 Jan 2023 14:30:13 +0530 Subject: [PATCH 053/407] fix: `fetch_if_empty` not honoured on the client-side --- frappe/public/js/frappe/form/script_manager.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/script_manager.js b/frappe/public/js/frappe/form/script_manager.js index 045c67bbb3..a5511285a8 100644 --- a/frappe/public/js/frappe/form/script_manager.js +++ b/frappe/public/js/frappe/form/script_manager.js @@ -213,7 +213,12 @@ frappe.ui.form.ScriptManager = class ScriptManager { df.read_only == 1 || df.is_virtual == 1; - if (is_read_only_field && df.fetch_from && df.fetch_from.indexOf(".") != -1) { + if ( + is_read_only_field && + df.fetch_from && + (!df.fetch_if_empty || (df.fetch_if_empty && !me.frm.doc[df.fieldname])) && + df.fetch_from.indexOf(".") != -1 + ) { var parts = df.fetch_from.split("."); me.frm.add_fetch(parts[0], parts[1], df.fieldname, df.parent); } From 5340efd1567759c6fc532094beb453989b5c81d2 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Fri, 13 Jan 2023 16:21:51 +0530 Subject: [PATCH 054/407] fix: don't cast integer value in filter --- frappe/database/query.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index 726f930b19..7152b77dab 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -165,9 +165,7 @@ class Engine: (table.parent == self.table.name) & (table.parenttype == self.doctype) ) - if isinstance(_value, (str, int)): - _value = str(_value) - elif isinstance(_value, (list, tuple)): + if isinstance(_value, (list, tuple)): _operator, _value = _value elif isinstance(_value, bool): _value = int(_value) From a93380ac9c55135443f4c0ad5d4ac24efa6e75f2 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Fri, 13 Jan 2023 16:22:25 +0530 Subject: [PATCH 055/407] fix: handle empty list for "in" and "not in" --- frappe/database/query.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/database/query.py b/frappe/database/query.py index 7152b77dab..ea5f2b8760 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -173,6 +173,9 @@ class Engine: if isinstance(_value, str) and has_function(_value): _value = self.get_function_object(_value) + if isinstance(_value, (list, tuple)) and not _value: + _value = ("",) + # Nested set if _operator in OPERATOR_MAP["nested_set"]: hierarchy = _operator From d7a50e4dfd344f397b5614c40eac5962fbc862ef Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Fri, 13 Jan 2023 17:00:46 +0530 Subject: [PATCH 056/407] fix: Hide drop-icon if all children is hidden Hide section if all workspaces are hidden --- .../public/js/frappe/views/workspace/workspace.js | 14 ++++++++++++-- frappe/public/scss/desk/desktop.scss | 8 ++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js index ee5d2c92a0..780f995c5e 100644 --- a/frappe/public/js/frappe/views/workspace/workspace.js +++ b/frappe/public/js/frappe/views/workspace/workspace.js @@ -156,6 +156,10 @@ frappe.views.Workspace = class Workspace { if (Object.keys(root_pages).length === 0) { sidebar_section.addClass("hidden"); } + + if (sidebar_section.find("> [item-is-hidden='0']").length == 0) { + sidebar_section.addClass("hidden show-in-edit-mode"); + } } prepare_sidebar(items, child_container, item_container) { @@ -193,6 +197,10 @@ frappe.views.Workspace = class Workspace { } this.add_drop_icon(item, sidebar_control, $item_container); + + if (child_items.length > 0) { + $item_container.find(".drop-icon").first().addClass("show-in-edit-mode"); + } } add_drop_icon(item, sidebar_control, item_container) { @@ -206,7 +214,7 @@ frappe.views.Workspace = class Workspace { `` ).appendTo(sidebar_control); let pages = item.public ? this.public_pages : this.private_pages; - if (pages.some((e) => e.parent_page == item.title)) { + if (pages.some((e) => e.parent_page == item.title && e.is_hidden == 0)) { $drop_icon.removeClass("hidden"); } $drop_icon.on("click", () => { @@ -251,7 +259,9 @@ frappe.views.Workspace = class Workspace { if (sidebar_page) sidebar_page.selected = true; // open child sidebar section if closed - $sidebar.parent().hasClass("hidden") && $sidebar.parent().removeClass("hidden"); + $sidebar.parent().hasClass("sidebar-child-item") && + $sidebar.parent().hasClass("hidden") && + $sidebar.parent().removeClass("hidden"); this.current_page = { name: page.name, public: page.public }; localStorage.current_page = page.name; diff --git a/frappe/public/scss/desk/desktop.scss b/frappe/public/scss/desk/desktop.scss index dbaba1e4d0..12014faf0f 100644 --- a/frappe/public/scss/desk/desktop.scss +++ b/frappe/public/scss/desk/desktop.scss @@ -1057,6 +1057,14 @@ body { } } } + + .show-in-edit-mode { + display: block !important; + + &.drop-icon { + display: inline-block !important; + } + } } .standard-sidebar-section.show-control { From 66d35ed3f46cdc644a9dcdcf50e1a92c03f96ef7 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Fri, 13 Jan 2023 17:08:46 +0530 Subject: [PATCH 057/407] fix: if workspace has children always show drop-icon in edit mode --- frappe/public/js/frappe/views/workspace/workspace.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js index 780f995c5e..76f528dad7 100644 --- a/frappe/public/js/frappe/views/workspace/workspace.js +++ b/frappe/public/js/frappe/views/workspace/workspace.js @@ -214,7 +214,11 @@ frappe.views.Workspace = class Workspace { `` ).appendTo(sidebar_control); let pages = item.public ? this.public_pages : this.private_pages; - if (pages.some((e) => e.parent_page == item.title && e.is_hidden == 0)) { + if ( + pages.some( + (e) => e.parent_page == item.title && (e.is_hidden == 0 || !this.is_read_only) + ) + ) { $drop_icon.removeClass("hidden"); } $drop_icon.on("click", () => { From c41b5e9511f81d0919ffd5ed9e149c5e7cbe8ee1 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 13 Jan 2023 17:00:15 +0530 Subject: [PATCH 058/407] fix: Report sidebar must consider Permission Query - On boot cache permissible reports, filter out reports blocked by Permission Query - Sidebar report selector uses boot cache to get allowed reports, which now respects Permission Query - Convert qb query to str and append permission query and then execute Co-authored-by: Gavin D'souza --- frappe/boot.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/frappe/boot.py b/frappe/boot.py index 31e101aedc..122de4fc95 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -3,6 +3,7 @@ """ bootstrap client session """ +from typing import TYPE_CHECKING import frappe import frappe.defaults @@ -12,6 +13,7 @@ from frappe.desk.doctype.route_history.route_history import frequently_visited_l from frappe.desk.form.load import get_meta_bundle from frappe.email.inbox import get_email_accounts from frappe.model.base_document import get_controller +from frappe.model.db_query import DatabaseQuery from frappe.query_builder import DocType from frappe.query_builder.functions import Count from frappe.query_builder.terms import ParameterizedValueWrapper, SubQuery @@ -24,6 +26,9 @@ from frappe.utils import add_user_info, cstr, get_time_zone from frappe.utils.change_log import get_versions from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabled +if TYPE_CHECKING: + from pypika.dialects import MySQLQueryBuilder + def get_bootinfo(): """build and return boot info""" @@ -182,7 +187,8 @@ def get_user_pages_or_reports(parent, cache=False): & (customRole[parent.lower()].isnotnull()) & (hasRole.role.isin(roles)) ) - ).run(as_dict=True) + ) + pages_with_custom_roles = run_with_permission_query(parent, pages_with_custom_roles) for p in pages_with_custom_roles: has_role[p.name] = {"modified": p.modified, "title": p.title, "ref_doctype": p.ref_doctype} @@ -208,7 +214,7 @@ def get_user_pages_or_reports(parent, cache=False): if parent == "Report": pages_with_standard_roles = pages_with_standard_roles.where(report.disabled == 0) - pages_with_standard_roles = pages_with_standard_roles.run(as_dict=True) + pages_with_standard_roles = run_with_permission_query(parent, pages_with_standard_roles) for p in pages_with_standard_roles: if p.name not in has_role: @@ -222,12 +228,12 @@ def get_user_pages_or_reports(parent, cache=False): # pages with no role are allowed if parent == "Page": - pages_with_no_roles = ( frappe.qb.from_(parentTable) .select(parentTable.name, parentTable.modified, *columns) .where(no_of_roles == 0) - ).run(as_dict=True) + ) + pages_with_no_roles = run_with_permission_query(parent, pages_with_no_roles) for p in pages_with_no_roles: if p.name not in has_role: @@ -248,6 +254,21 @@ def get_user_pages_or_reports(parent, cache=False): return has_role +def run_with_permission_query(doctype: str, query: "MySQLQueryBuilder") -> list[dict]: + """ + Adds Permission Query (Server Script) conditions and runs/executes modified query + Note: Works only if 'WHERE' is the last clause in the query + """ + db_query = DatabaseQuery(doctype, frappe.session.user) + permission_query = db_query.get_permission_query_conditions() + + query = query.get_sql() + if permission_query: + query = query + " AND " + permission_query + + return frappe.db.sql(query, as_dict=True) + + def load_translations(bootinfo): bootinfo["lang"] = frappe.lang bootinfo["__messages"] = get_messages_for_boot() From 0ba158979de31bdae879b61aad6a05f9c4a41477 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 13 Jan 2023 18:36:12 +0530 Subject: [PATCH 059/407] fix: Make `run_with_permission_query` private (not a general util) - Also switch query and doctype parameter positions in `_run_with_permission_query` --- frappe/boot.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frappe/boot.py b/frappe/boot.py index 122de4fc95..a84007a195 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -188,7 +188,7 @@ def get_user_pages_or_reports(parent, cache=False): & (hasRole.role.isin(roles)) ) ) - pages_with_custom_roles = run_with_permission_query(parent, pages_with_custom_roles) + pages_with_custom_roles = _run_with_permission_query(pages_with_custom_roles, parent) for p in pages_with_custom_roles: has_role[p.name] = {"modified": p.modified, "title": p.title, "ref_doctype": p.ref_doctype} @@ -214,7 +214,7 @@ def get_user_pages_or_reports(parent, cache=False): if parent == "Report": pages_with_standard_roles = pages_with_standard_roles.where(report.disabled == 0) - pages_with_standard_roles = run_with_permission_query(parent, pages_with_standard_roles) + pages_with_standard_roles = _run_with_permission_query(pages_with_standard_roles, parent) for p in pages_with_standard_roles: if p.name not in has_role: @@ -233,7 +233,7 @@ def get_user_pages_or_reports(parent, cache=False): .select(parentTable.name, parentTable.modified, *columns) .where(no_of_roles == 0) ) - pages_with_no_roles = run_with_permission_query(parent, pages_with_no_roles) + pages_with_no_roles = _run_with_permission_query(pages_with_no_roles, parent) for p in pages_with_no_roles: if p.name not in has_role: @@ -254,7 +254,7 @@ def get_user_pages_or_reports(parent, cache=False): return has_role -def run_with_permission_query(doctype: str, query: "MySQLQueryBuilder") -> list[dict]: +def _run_with_permission_query(query: "MySQLQueryBuilder", doctype: str) -> list[dict]: """ Adds Permission Query (Server Script) conditions and runs/executes modified query Note: Works only if 'WHERE' is the last clause in the query @@ -266,7 +266,7 @@ def run_with_permission_query(doctype: str, query: "MySQLQueryBuilder") -> list[ if permission_query: query = query + " AND " + permission_query - return frappe.db.sql(query, as_dict=True) + return frappe.db.sql(query, as_dict=True) # nosemgrep def load_translations(bootinfo): From c2ceceea6e2934754f910d1e25c307ae36b0e179 Mon Sep 17 00:00:00 2001 From: phot0n Date: Fri, 13 Jan 2023 17:17:17 +0530 Subject: [PATCH 060/407] fix: use OAUTHLIB_RELAX_TOKEN_SCOPE for ignoring scope change without this we get an error regarding the mismatch of scopes from microsoft --- frappe/integrations/doctype/connected_app/connected_app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index ca9677d4da..4137e6b85b 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -14,6 +14,8 @@ if any((os.getenv("CI"), frappe.conf.developer_mode, frappe.conf.allow_tests)): # Disable mandatory TLS in developer mode and tests os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" +os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1" + class ConnectedApp(Document): """Connect to a remote oAuth Server. Retrieve and store user's access token From 88d4d5e10ddff41788523e8f4705d7410b171898 Mon Sep 17 00:00:00 2001 From: phot0n Date: Sun, 15 Jan 2023 20:53:18 +0530 Subject: [PATCH 061/407] chore: minor cleanup - removed unnecessary branches and comments --- frappe/email/doctype/email_account/email_account.py | 11 +++-------- frappe/email/oauth.py | 3 +-- .../doctype/connected_app/connected_app.py | 2 +- 3 files changed, 5 insertions(+), 11 deletions(-) diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index 1e08ce5615..d20dd995ba 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -667,12 +667,9 @@ class EmailAccount(Document): self.log_error("Unable to add to Sent folder") def get_oauth_token(self): - token = None if self.auth_method == "OAuth": connected_app = frappe.get_doc("Connected App", self.connected_app) - token = connected_app.get_active_token(self.connected_user) - - return token + return connected_app.get_active_token(self.connected_user) @frappe.whitelist() @@ -769,14 +766,12 @@ def notify_unreplied(): def pull(now=False): """Will be called via scheduler, pull emails from all enabled Email accounts.""" + from frappe.integrations.doctype.connected_app.connected_app import has_token if frappe.cache().get_value("workers:no-internet") == True: if test_internet(): frappe.cache().set_value("workers:no-internet", False) - else: - return - - from frappe.integrations.doctype.connected_app.connected_app import has_token + return doctype = frappe.qb.DocType("Email Account") email_accounts = ( diff --git a/frappe/email/oauth.py b/frappe/email/oauth.py index 6b56047069..87feb8ca11 100644 --- a/frappe/email/oauth.py +++ b/frappe/email/oauth.py @@ -36,7 +36,6 @@ class Oauth: return f"user={self.email}\1auth=Bearer {self._access_token}\1\1" def connect(self) -> None: - """Connection method with retry on exception for connection errors""" try: if isinstance(self._conn, POP3): self._connect_pop() @@ -59,7 +58,7 @@ class Oauth: raise def _connect_pop(self) -> None: - # poplib doesn't have AUTH command implementation + # NOTE: poplib doesn't have AUTH command implementation res = self._conn._shortcmd( "AUTH {} {}".format( self._mechanism, base64.b64encode(bytes(self._auth_string, "utf-8")).decode("utf-8") diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index 4137e6b85b..96f23afc1c 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -167,4 +167,4 @@ def callback(code=None, state=None): def has_token(connected_app, connected_user=None): app = frappe.get_doc("Connected App", connected_app) token_cache = app.get_token_cache(connected_user or frappe.session.user) - return bool(token_cache.get_json()["access_token"]) + return bool(token_cache.get_password("access_token", False)) From 8abd144de650be29ea4d232a5d2c598153ad51ba Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 16 Jan 2023 10:24:45 +0530 Subject: [PATCH 062/407] test: fixed failing UI test --- cypress/integration/form_builder.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cypress/integration/form_builder.js b/cypress/integration/form_builder.js index 84494ddebf..a02febedc1 100644 --- a/cypress/integration/form_builder.js +++ b/cypress/integration/form_builder.js @@ -87,7 +87,7 @@ context("Form Builder", () => { cy.get_open_dialog().find(".msgprint").should("contain", "Options is required"); cy.hide_dialog(); - cy.get(first_field).click(); + cy.get(first_field).click({ force: true }); cy.get(".sidebar-container .frappe-control[data-fieldname='options'] input") .click() @@ -114,7 +114,7 @@ context("Form Builder", () => { cy.get_open_dialog().find(".msgprint").should("contain", "In List View"); cy.hide_dialog(); - cy.get(first_field).click(); + cy.get(first_field).click({ force: true }); cy.get(".sidebar-container .field label .label-area").contains("In List View").click(); // validate In Global Search From bfaadfd32d32f0deb97f720055dfbcfccc7e546d Mon Sep 17 00:00:00 2001 From: aissa-berrachiche <91866930+aissa-berrachiche@users.noreply.github.com> Date: Mon, 16 Jan 2023 08:36:05 +0300 Subject: [PATCH 063/407] fix: passwords are updated on every login (#19594) Co-authored-by: aberrachiche --- frappe/utils/password.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/utils/password.py b/frappe/utils/password.py index 9a2aa04bf7..c033f4682b 100644 --- a/frappe/utils/password.py +++ b/frappe/utils/password.py @@ -118,7 +118,7 @@ def check_password(user, pwd, doctype="User", fieldname="password", delete_track if delete_tracker_cache: delete_login_failed_cache(user) - if not passlibctx.needs_update(result[0].password): + if passlibctx.needs_update(result[0].password): update_password(user, pwd, doctype, fieldname) return user From 197de99e35b14e661d0ab3a34ab4497396148168 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 16 Jan 2023 11:07:56 +0530 Subject: [PATCH 064/407] refactor: Use `permitted` over `available` in variable naming --- frappe/model/db_query.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index a5a6dcd91c..f143541fa0 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -561,7 +561,7 @@ class DatabaseQuery: if self.flags.ignore_permissions: return - available_fields = get_permitted_fields(doctype=self.doctype) + permitted_fields = get_permitted_fields(doctype=self.doctype) for i, field in enumerate(self.fields): if "distinct" in field: @@ -571,10 +571,10 @@ class DatabaseQuery: column = field.split(" ", 1)[0].replace("`", "") if column == "*": - self.fields[i : i + 1] = available_fields + self.fields[i : i + 1] = permitted_fields # labels / pseudo columns or frappe internals - elif column[0] in {"'", '"', "_"} or column in available_fields: + elif column[0] in {"'", '"', "_"} or column in permitted_fields: continue # handle child / joined table fields @@ -584,10 +584,10 @@ class DatabaseQuery: if table in self.tables: ch_doctype = table.replace("`", "").replace("tab", "", 1) - available_child_table_fields = get_permitted_fields( + permitted_child_table_fields = get_permitted_fields( doctype=ch_doctype, parenttype=self.doctype ) - if column in available_child_table_fields: + if column in permitted_child_table_fields: continue else: self.fields.remove(field) @@ -596,13 +596,13 @@ class DatabaseQuery: elif "(" in field: if "*" in field: continue - elif any(x for x in available_fields if x in field): + elif any(x for x in permitted_fields if x in field): continue elif _params := FN_PARAMS_PATTERN.findall(column): params = (x for x in _params[0].split(",")) for param in params: if ( - not param or param in available_fields or param.isnumeric() or "'" in param or '"' in param + not param or param in permitted_fields or param.isnumeric() or "'" in param or '"' in param ): continue else: From b5c81cc0152bc7a365f0f2f82d41ab5803d9b1bf Mon Sep 17 00:00:00 2001 From: phot0n Date: Sun, 15 Jan 2023 21:01:03 +0530 Subject: [PATCH 065/407] chore(patch): disable all email accounts with oauth mechanism --- .../email/doctype/email_account/email_account.py | 2 +- frappe/patches.txt | 1 + .../v14_0/disable_email_accounts_with_oauth.py | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 frappe/patches/v14_0/disable_email_accounts_with_oauth.py diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index d20dd995ba..f754869938 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -911,7 +911,7 @@ def remove_user_email_inbox(email_account): @frappe.whitelist() def set_email_password(email_account, password): account = frappe.get_doc("Email Account", email_account) - if account.awaiting_password and not account.auth_method == "OAuth": + if account.awaiting_password and account.auth_method != "OAuth": account.awaiting_password = 0 account.password = password try: diff --git a/frappe/patches.txt b/frappe/patches.txt index cf1e509e78..b345e7f106 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -198,6 +198,7 @@ frappe.patches.v14_0.delete_payment_gateways frappe.patches.v15_0.remove_event_streaming frappe.patches.v15_0.remove_prepared_report_settings_from_system_settings frappe.patches.v15_0.copy_disable_prepared_report_to_prepared_report +frappe.patches.v14_0.disable_email_accounts_with_oauth [post_model_sync] frappe.patches.v14_0.drop_data_import_legacy diff --git a/frappe/patches/v14_0/disable_email_accounts_with_oauth.py b/frappe/patches/v14_0/disable_email_accounts_with_oauth.py new file mode 100644 index 0000000000..27c322c60a --- /dev/null +++ b/frappe/patches/v14_0/disable_email_accounts_with_oauth.py @@ -0,0 +1,15 @@ +import click + +import frappe + + +def execute(): + # Setting awaiting password to 1 for email accounts where Oauth is enabled. + # This is done so that people can resetup their email accounts with connected app mechanism. + doctype = frappe.qb.DocType("Email Account") + frappe.qb.update(doctype).set(doctype.awaiting_password, 1).where(doctype.auth_mehtod == "OAuth") + + click.secho( + "Email Accounts with auth method as OAuth have been disabled." + "Please re-setup your OAuth based email accounts with the connected app mechanism to re-enable them." + ) From d8b7bc18d7453cf37148b716ed013f25394dc157 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Thu, 18 Aug 2022 11:40:29 +0530 Subject: [PATCH 066/407] refactor!: deprecate sorting based on `apps.txt` in `get_installed_apps` --- frappe/__init__.py | 9 +++++++-- frappe/utils/change_log.py | 2 +- frappe/utils/jinja.py | 3 +-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index b7fd117868..23d40f08a9 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1400,7 +1400,12 @@ def get_all_apps(with_internal_apps=True, sites_path=None): @request_cache def get_installed_apps(sort=False, frappe_last=False): - """Get list of installed apps in current site.""" + """ + Get list of installed apps in current site. + + :param sort: [DEPRECATED] Sort installed apps based on the sequence in sites/apps.txt + """ + if getattr(flags, "in_install_db", True): return [] @@ -1445,7 +1450,7 @@ def _load_app_hooks(app_name: str | None = None): import types hooks = {} - apps = [app_name] if app_name else get_installed_apps(sort=True) + apps = [app_name] if app_name else get_installed_apps() for app in apps: try: diff --git a/frappe/utils/change_log.py b/frappe/utils/change_log.py index 12069cce09..6850a9bbf9 100644 --- a/frappe/utils/change_log.py +++ b/frappe/utils/change_log.py @@ -108,7 +108,7 @@ def get_versions(): } }""" versions = {} - for app in frappe.get_installed_apps(sort=True): + for app in frappe.get_installed_apps(): app_hooks = frappe.get_hooks(app_name=app) versions[app] = { "title": app_hooks.get("app_title")[0], diff --git a/frappe/utils/jinja.py b/frappe/utils/jinja.py index 33bb929bc4..0e5c03eff0 100644 --- a/frappe/utils/jinja.py +++ b/frappe/utils/jinja.py @@ -112,8 +112,7 @@ def get_jloader(): apps = frappe.get_hooks("template_apps") if not apps: - apps = frappe.local.flags.web_pages_apps or frappe.get_installed_apps(sort=True) - apps.reverse() + apps = list(reversed(frappe.local.flags.web_pages_apps or frappe.get_installed_apps())) if "frappe" not in apps: apps.append("frappe") From f5cbcec10342b9f15cd689a589cd6843b2172e11 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Thu, 18 Aug 2022 12:08:33 +0530 Subject: [PATCH 067/407] fix: defer `local.all_apps` loading --- frappe/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index 23d40f08a9..e93b16a79c 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1412,12 +1412,12 @@ def get_installed_apps(sort=False, frappe_last=False): if not db: connect() - if not local.all_apps: - local.all_apps = cache().get_value("all_apps", get_all_apps) - installed = json.loads(db.get_global("installed_apps") or "[]") if sort: + if not local.all_apps: + local.all_apps = cache().get_value("all_apps", get_all_apps) + installed = [app for app in local.all_apps if app in installed] if frappe_last: From 5e2bbf834f8eff7db340f7a760771bf628a61442 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 13 Jan 2023 13:50:01 +0530 Subject: [PATCH 068/407] refactor: filter out apps not installed on bench --- frappe/__init__.py | 13 +++++++++++-- frappe/tests/test_commands.py | 1 + frappe/utils/change_log.py | 2 +- frappe/utils/jinja.py | 4 +++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index e93b16a79c..b4b44925ef 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1399,12 +1399,15 @@ def get_all_apps(with_internal_apps=True, sites_path=None): @request_cache -def get_installed_apps(sort=False, frappe_last=False): +def get_installed_apps(sort=False, frappe_last=False, *, _ensure_on_bench=False): """ Get list of installed apps in current site. :param sort: [DEPRECATED] Sort installed apps based on the sequence in sites/apps.txt + :param frappe_last: [DEPRECATED] Keep frappe last. Do not use this, reverse the app list instead. + :param ensure_on_bench: Only return apps that are present on bench. """ + from frappe.utils.deprecations import deprecation_warning if getattr(flags, "in_install_db", True): return [] @@ -1418,9 +1421,15 @@ def get_installed_apps(sort=False, frappe_last=False): if not local.all_apps: local.all_apps = cache().get_value("all_apps", get_all_apps) + deprecation_warning("`sort` argument is deprecated and will be removed in v15.") installed = [app for app in local.all_apps if app in installed] + if _ensure_on_bench: + all_apps = cache().get_value("all_apps", get_all_apps) + installed = [app for app in installed if app in all_apps] + if frappe_last: + deprecation_warning("`frappe_last` argument is deprecated and will be removed in v15.") if "frappe" in installed: installed.remove("frappe") installed.append("frappe") @@ -1450,7 +1459,7 @@ def _load_app_hooks(app_name: str | None = None): import types hooks = {} - apps = [app_name] if app_name else get_installed_apps() + apps = [app_name] if app_name else get_installed_apps(_ensure_on_bench=True) for app in apps: try: diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 7af66f6c62..bbf51de884 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -300,6 +300,7 @@ class TestCommands(BaseTestCommands): frappe.local.cache = {} self.assertEqual(frappe.recorder.status(), False) + @unittest.skip("Poorly written, relied on app name being absent in apps.txt") def test_remove_from_installed_apps(self): app = "test_remove_app" add_to_installed_apps(app) diff --git a/frappe/utils/change_log.py b/frappe/utils/change_log.py index 6850a9bbf9..55534614e6 100644 --- a/frappe/utils/change_log.py +++ b/frappe/utils/change_log.py @@ -108,7 +108,7 @@ def get_versions(): } }""" versions = {} - for app in frappe.get_installed_apps(): + for app in frappe.get_installed_apps(_ensure_on_bench=True): app_hooks = frappe.get_hooks(app_name=app) versions[app] = { "title": app_hooks.get("app_title")[0], diff --git a/frappe/utils/jinja.py b/frappe/utils/jinja.py index 0e5c03eff0..d92df18c70 100644 --- a/frappe/utils/jinja.py +++ b/frappe/utils/jinja.py @@ -112,7 +112,9 @@ def get_jloader(): apps = frappe.get_hooks("template_apps") if not apps: - apps = list(reversed(frappe.local.flags.web_pages_apps or frappe.get_installed_apps())) + apps = list( + reversed(frappe.local.flags.web_pages_apps or frappe.get_installed_apps(_ensure_on_bench=True)) + ) if "frappe" not in apps: apps.append("frappe") From 0355f33b7725a29737e237661590e158f661b0ec Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 16 Jan 2023 13:54:56 +0530 Subject: [PATCH 069/407] fix(db_query): Handle permlevel check cases clearer - Split to utility functions for clarity - Add example over code blocks - Re-arrange blocks based on priority --- frappe/model/db_query.py | 66 ++++++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index f143541fa0..988b352be9 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -564,25 +564,38 @@ class DatabaseQuery: permitted_fields = get_permitted_fields(doctype=self.doctype) for i, field in enumerate(self.fields): - if "distinct" in field: - self.distinct = True - column = field.split(" ", 2)[1].replace("`", "") - else: - column = field.split(" ", 1)[0].replace("`", "") + # field: like 'name', 'published' + if is_plain_field(field) and field not in permitted_fields: + self.fields.remove(field) + continue - if column == "*": + if "distinct" in field.lower(): + # field: 'count(distinct `tabPhoto`.name) as total_count' + # column: 'tabPhoto.name' + self.distinct = True + column = field.split(" ", 2)[1].replace("`", "").replace(")", "") + else: + # field: 'count(`tabPhoto`.name) as total_count' + # column: 'tabPhoto.name' + column = field.split("(")[-1].split(")", 1)[0] + column = strip_alias(column).replace("`", "") + + if column == "*" and not in_function("*", field): self.fields[i : i + 1] = permitted_fields + # handle pseudo columns + elif not column: + continue + # labels / pseudo columns or frappe internals - elif column[0] in {"'", '"', "_"} or column in permitted_fields: + elif column[0] in {"'", '"', "_"}: continue # handle child / joined table fields elif "." in field: - table, _column = field.split(".", 1) - column = _column.lower().split(" ", 1)[0].replace("`", "") + table, column = column.split(".", 1) - if table in self.tables: + if wrap_grave_quotes(table) in self.tables: ch_doctype = table.replace("`", "").replace("tab", "", 1) permitted_child_table_fields = get_permitted_fields( doctype=ch_doctype, parenttype=self.doctype @@ -592,6 +605,9 @@ class DatabaseQuery: else: self.fields.remove(field) + elif column in permitted_fields: + continue + # field inside function calls / * handles things like count(*) elif "(" in field: if "*" in field: @@ -605,8 +621,7 @@ class DatabaseQuery: not param or param in permitted_fields or param.isnumeric() or "'" in param or '"' in param ): continue - else: - self.fields.remove(field) + self.fields.remove(field) # remove if access not allowed else: @@ -1230,3 +1245,30 @@ def get_permitted_fields(doctype, parenttype=None): meta_fields.remove("idx") return meta_fields + accessible_fields + optional_meta_fields + + +def wrap_grave_quotes(table: str) -> str: + if table[0] != "`": + table = f"`{table}`" + return table + + +def is_plain_field(field: str) -> bool: + for char in field: + if char in ("(", "`", ".", "'", '"', "*"): + return False + return True + + +def in_function(substr: str, field: str) -> bool: + try: + return substr in field and field.index("(") < field.index(substr) < field.index(")") + except ValueError: + return False + + +def strip_alias(field: str) -> str: + # Note: Currently only supports aliases that use the " AS " syntax + if " as " in field.lower(): + return field.split(" as ", 1)[0] + return field From 7e38d7fe6349007fc86a388d4aa937e89074350c Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 16 Jan 2023 13:56:37 +0530 Subject: [PATCH 070/407] test: Make test_child_table_join more resilient --- frappe/tests/test_db_query.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index ce4b4b6186..110ebe68c0 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -102,7 +102,6 @@ class TestReportview(FrappeTestCase): "name": "Parent DocType 1", "module": "Custom", "custom": 1, - "autoname": "autoincrement", "fields": [ {"label": "Title", "fieldname": "title", "fieldtype": "Data"}, { @@ -122,7 +121,6 @@ class TestReportview(FrappeTestCase): "name": "Parent DocType 2", "module": "Custom", "custom": 1, - "autoname": "autoincrement", "fields": [ {"label": "Title", "fieldname": "title", "fieldtype": "Data"}, { @@ -146,10 +144,14 @@ class TestReportview(FrappeTestCase): doctype="Parent DocType 1", title="test", child=[{"title": "parent 1 child record 1"}, {"title": "parent 1 child record 2"}], - ).insert() + __newname="test_parent", + ).insert(ignore_if_duplicate=True) frappe.get_doc( - doctype="Parent DocType 2", title="test", child=[{"title": "parent 2 child record 1"}] - ).insert() + doctype="Parent DocType 2", + title="test", + child=[{"title": "parent 2 child record 1"}], + __newname="test_parent", + ).insert(ignore_if_duplicate=True) # test query results1 = frappe.get_all("Parent DocType 1", fields=["name", "child.title as child_title"]) From 52e3d8d58b7fd62619bcd689b3599d501702cf57 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 16 Jan 2023 14:11:37 +0530 Subject: [PATCH 071/407] fix: handle empty string passed to filters --- frappe/database/query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index ea5f2b8760..072f1eed4f 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -98,7 +98,7 @@ class Engine: self, filters: dict[str, str | int] | str | int | list[list | str | int] | None = None, ): - if not filters: + if filters is None: return if isinstance(filters, (str, int)): From 5bc5ff100bffc0210ddddab8c8513b6e8aa02dd6 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 16 Jan 2023 14:12:04 +0530 Subject: [PATCH 072/407] test: tests for various filter options --- frappe/tests/test_query.py | 41 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/frappe/tests/test_query.py b/frappe/tests/test_query.py index 12cb6446d2..42d3670f2d 100644 --- a/frappe/tests/test_query.py +++ b/frappe/tests/test_query.py @@ -258,6 +258,47 @@ class TestQuery(FrappeTestCase): ), ) + self.assertEqual( + frappe.qb.get_query( + "DocType", + fields=["module"], + filters="", + ).get_sql(), + "SELECT `module` FROM `tabDocType` WHERE `name`=''".replace( + "`", '"' if frappe.db.db_type == "postgres" else "`" + ), + ) + + self.assertEqual( + frappe.qb.get_query( + "DocType", + filters=["ToDo", "Note"], + ).get_sql(), + "SELECT `name` FROM `tabDocType` WHERE `name` IN ('ToDo','Note')".replace( + "`", '"' if frappe.db.db_type == "postgres" else "`" + ), + ) + + self.assertEqual( + frappe.qb.get_query( + "DocType", + filters={"name": ("in", [])}, + ).get_sql(), + "SELECT `name` FROM `tabDocType` WHERE `name` IN ('')".replace( + "`", '"' if frappe.db.db_type == "postgres" else "`" + ), + ) + + self.assertEqual( + frappe.qb.get_query( + "DocType", + filters=[1, 2, 3], + ).get_sql(), + "SELECT `name` FROM `tabDocType` WHERE `name` IN (1,2,3)".replace( + "`", '"' if frappe.db.db_type == "postgres" else "`" + ), + ) + def test_implicit_join_query(self): self.maxDiff = None From f0a282e941545bd22b5c26686072527dcff532da Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 16 Jan 2023 14:28:13 +0530 Subject: [PATCH 073/407] test: Add test for linked table permission check --- frappe/tests/test_db_query.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 110ebe68c0..3b7f9ae9ca 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -844,6 +844,12 @@ class TestReportview(FrappeTestCase): self.assertTrue("count" in data[0]) self.assertEqual(len(data[0]), 2) + with self.assertRaises(frappe.PermissionError): + frappe.get_list( + "Blog Post", + fields=["blog_category.description"], + ) + def test_reportview_get_permlevel_system_users(self): with setup_patched_blog_post(), setup_test_user(set_user=True): frappe.local.request = frappe._dict() From 85d6949d04cc50769f4f53bb3dc2696107c3e5c9 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 16 Jan 2023 14:28:47 +0530 Subject: [PATCH 074/407] fix: Raise PermissionError when user doesnt have access to linked table --- frappe/model/db_query.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 988b352be9..d228c8a017 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -594,9 +594,9 @@ class DatabaseQuery: # handle child / joined table fields elif "." in field: table, column = column.split(".", 1) + ch_doctype = table.replace("`", "").replace("tab", "", 1) if wrap_grave_quotes(table) in self.tables: - ch_doctype = table.replace("`", "").replace("tab", "", 1) permitted_child_table_fields = get_permitted_fields( doctype=ch_doctype, parenttype=self.doctype ) @@ -604,6 +604,8 @@ class DatabaseQuery: continue else: self.fields.remove(field) + else: + raise frappe.PermissionError(ch_doctype) elif column in permitted_fields: continue From 47bcc527f382ea9ed8afa094c259aafa2dbf8bff Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 16 Jan 2023 14:51:31 +0530 Subject: [PATCH 075/407] fix: Remove logic short circuit / dont reject fields easily --- frappe/model/db_query.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index d228c8a017..c7878f703b 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -564,11 +564,6 @@ class DatabaseQuery: permitted_fields = get_permitted_fields(doctype=self.doctype) for i, field in enumerate(self.fields): - # field: like 'name', 'published' - if is_plain_field(field) and field not in permitted_fields: - self.fields.remove(field) - continue - if "distinct" in field.lower(): # field: 'count(distinct `tabPhoto`.name) as total_count' # column: 'tabPhoto.name' @@ -582,9 +577,10 @@ class DatabaseQuery: if column == "*" and not in_function("*", field): self.fields[i : i + 1] = permitted_fields + continue # handle pseudo columns - elif not column: + elif not column or column.isnumeric(): continue # labels / pseudo columns or frappe internals From 543458b473491db2e37683044ca8d01abe41290b Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Mon, 16 Jan 2023 15:38:15 +0530 Subject: [PATCH 076/407] fix: handle empty list as filters --- frappe/database/query.py | 2 +- frappe/tests/test_query.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/frappe/database/query.py b/frappe/database/query.py index 072f1eed4f..10423f9ca4 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -111,7 +111,7 @@ class Engine: self.apply_dict_filters(filters) elif isinstance(filters, (list, tuple)): - if all(isinstance(d, (str, int)) for d in filters): + if all(isinstance(d, (str, int)) for d in filters) and len(filters) > 0: self.apply_dict_filters({"name": ("in", filters)}) else: for filter in filters: diff --git a/frappe/tests/test_query.py b/frappe/tests/test_query.py index 42d3670f2d..82218e5952 100644 --- a/frappe/tests/test_query.py +++ b/frappe/tests/test_query.py @@ -299,6 +299,14 @@ class TestQuery(FrappeTestCase): ), ) + self.assertEqual( + frappe.qb.get_query( + "DocType", + filters=[], + ).get_sql(), + "SELECT `name` FROM `tabDocType`".replace("`", '"' if frappe.db.db_type == "postgres" else "`"), + ) + def test_implicit_join_query(self): self.maxDiff = None From 1e6086fd757c3ff1a7d8b59b48a6b79e283ef02e Mon Sep 17 00:00:00 2001 From: Ritwik Puri Date: Mon, 16 Jan 2023 16:22:38 +0530 Subject: [PATCH 077/407] fix(minor): only show authorization message if connected app is set (#19605) --- frappe/email/doctype/email_account/email_account.js | 2 +- frappe/integrations/doctype/connected_app/connected_app.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index bc3d168639..90f71bf88f 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -166,7 +166,7 @@ frappe.ui.form.on("Email Account", { }, show_oauth_authorization_message(frm) { - if (frm.doc.auth_method === "OAuth") { + if (frm.doc.auth_method === "OAuth" && frm.doc.connected_app) { frappe.call({ method: "frappe.integrations.doctype.connected_app.connected_app.has_token", args: { diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index 96f23afc1c..536b63fe7b 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -167,4 +167,4 @@ def callback(code=None, state=None): def has_token(connected_app, connected_user=None): app = frappe.get_doc("Connected App", connected_app) token_cache = app.get_token_cache(connected_user or frappe.session.user) - return bool(token_cache.get_password("access_token", False)) + return bool(token_cache and token_cache.get_password("access_token", False)) From 0f5d17663d995986e74bb3145b4c76d73b70664b Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 16 Jan 2023 16:44:04 +0530 Subject: [PATCH 078/407] fix: Remove setup wizard user image (#19601) --- frappe/desk/page/setup_wizard/setup_wizard.js | 40 ++++++------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/frappe/desk/page/setup_wizard/setup_wizard.js b/frappe/desk/page/setup_wizard/setup_wizard.js index 1cfceb29b0..969aedb882 100644 --- a/frappe/desk/page/setup_wizard/setup_wizard.js +++ b/frappe/desk/page/setup_wizard/setup_wizard.js @@ -244,7 +244,7 @@ frappe.setup.SetupWizard = class SetupWizard extends frappe.ui.Slides { } get_setup_slides_filtered_by_domain() { - var filtered_slides = []; + let filtered_slides = []; frappe.setup.slides.forEach(function (slide) { if (frappe.setup.domains) { let active_domains = frappe.setup.domains; @@ -329,7 +329,7 @@ frappe.setup.SetupWizardSlide = class SetupWizardSlide extends frappe.ui.Slide { } set_init_values() { - var me = this; + let me = this; // set values from frappe.setup.values if (frappe.wizard.values && this.fields) { this.fields.forEach(function (f) { @@ -348,7 +348,7 @@ frappe.setup.slides_settings = [ { // Welcome (language) slide name: "welcome", - title: __("Hello!"), + title: __("Welcome"), fields: [ { @@ -418,16 +418,9 @@ frappe.setup.slides_settings = [ { // Profile slide name: "user", - title: __("The First User: You"), + title: __("Let's setup your account"), icon: "fa fa-user", fields: [ - { - fieldtype: "Attach Image", - fieldname: "attach_user_image", - label: __("Attach Your Picture"), - is_private: 0, - align: "center", - }, { fieldname: "full_name", label: __("Full Name"), @@ -456,15 +449,6 @@ frappe.setup.slides_settings = [ [frappe.boot.user.first_name, frappe.boot.user.last_name].join(" ").trim() ); } - - var user_image = frappe.get_cookie("user_image"); - var $attach_user_image = slide.form.fields_dict.attach_user_image.$wrapper; - - if (user_image) { - $attach_user_image.find(".missing-image").toggle(false); - $attach_user_image.find("img").attr("src", decodeURIComponent(user_image)); - $attach_user_image.find(".img-container").toggle(true); - } delete slide.form.fields_dict.email; } else { slide.form.fields_dict.email.df.reqd = 1; @@ -484,7 +468,7 @@ frappe.setup.slides_settings = [ let email = frappe.setup.data.email; slide.form.fields_dict.email.set_input(email); if (frappe.get_gravatar(email, 200)) { - var $attach_user_image = slide.form.fields_dict.attach_user_image.$wrapper; + let $attach_user_image = slide.form.fields_dict.attach_user_image.$wrapper; $attach_user_image.find(".missing-image").toggle(false); $attach_user_image.find("img").attr("src", frappe.get_gravatar(email, 200)); $attach_user_image.find(".img-container").toggle(true); @@ -569,7 +553,7 @@ frappe.setup.utils = { .on("change", function () { clearTimeout(slide.language_call_timeout); slide.language_call_timeout = setTimeout(() => { - var lang = $(this).val() || "English"; + let lang = $(this).val() || "English"; frappe._messages = {}; frappe.call({ method: "frappe.desk.page.setup_wizard.setup_wizard.load_messages", @@ -595,9 +579,9 @@ frappe.setup.utils = { Bind a slide's country, timezone and currency fields */ slide.get_input("country").on("change", function () { - var country = slide.get_input("country").val(); - var $timezone = slide.get_input("timezone"); - var data = frappe.setup.data.regional_data; + let country = slide.get_input("country").val(); + let $timezone = slide.get_input("timezone"); + let data = frappe.setup.data.regional_data; $timezone.empty(); @@ -618,12 +602,12 @@ frappe.setup.utils = { }); slide.get_input("currency").on("change", function () { - var currency = slide.get_input("currency").val(); + let currency = slide.get_input("currency").val(); if (!currency) return; frappe.model.with_doc("Currency", currency, function () { frappe.provide("locals.:Currency." + currency); - var currency_doc = frappe.model.get_doc("Currency", currency); - var number_format = currency_doc.number_format; + let currency_doc = frappe.model.get_doc("Currency", currency); + let number_format = currency_doc.number_format; if (number_format === "#.###") { number_format = "#.###,##"; } else if (number_format === "#,###") { From 2702bf60aa76b7f30178c7989388ead8e5527c7e Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 16 Jan 2023 16:48:24 +0530 Subject: [PATCH 079/407] refactor: Use `Query` instead of `MySQLQueryBuilder` - Use `Query` from database/utils which will consider postgres query type as well - Reduce LOC where unnecessary Co-authored-by: Gavin D'souza --- frappe/boot.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/frappe/boot.py b/frappe/boot.py index a84007a195..09956ea0d7 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -27,7 +27,7 @@ from frappe.utils.change_log import get_versions from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabled if TYPE_CHECKING: - from pypika.dialects import MySQLQueryBuilder + from frappe.database.utils import Query def get_bootinfo(): @@ -254,17 +254,14 @@ def get_user_pages_or_reports(parent, cache=False): return has_role -def _run_with_permission_query(query: "MySQLQueryBuilder", doctype: str) -> list[dict]: +def _run_with_permission_query(query: "Query", doctype: str) -> list[dict]: """ Adds Permission Query (Server Script) conditions and runs/executes modified query Note: Works only if 'WHERE' is the last clause in the query """ - db_query = DatabaseQuery(doctype, frappe.session.user) - permission_query = db_query.get_permission_query_conditions() - - query = query.get_sql() + permission_query = DatabaseQuery(doctype, frappe.session.user).get_permission_query_conditions() if permission_query: - query = query + " AND " + permission_query + query = f"{query.get_sql()} AND {permission_query}" return frappe.db.sql(query, as_dict=True) # nosemgrep From 8bd1b7b019844dec0b0261699cb0036a902a9b8a Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 16 Jan 2023 17:09:21 +0530 Subject: [PATCH 080/407] fix: Remove unnecessary `get_sql` --- frappe/boot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/boot.py b/frappe/boot.py index 09956ea0d7..bb8393e8dc 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -261,7 +261,7 @@ def _run_with_permission_query(query: "Query", doctype: str) -> list[dict]: """ permission_query = DatabaseQuery(doctype, frappe.session.user).get_permission_query_conditions() if permission_query: - query = f"{query.get_sql()} AND {permission_query}" + query = f"{query} AND {permission_query}" return frappe.db.sql(query, as_dict=True) # nosemgrep From 8adfdcbc1d7c9400a157b85e14015cfe7a11e72e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 16 Jan 2023 17:31:39 +0530 Subject: [PATCH 081/407] tests: clear DB transactions before all db calls Because of repeatable read isolation, changes from externally executed command dont reflect until transaction is ended. --- frappe/tests/test_commands.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index bbf51de884..66c78d825c 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -405,23 +405,26 @@ class TestCommands(BaseTestCommands): def test_set_password(self): from frappe.utils.password import check_password + self.assertEqual(check_password("Administrator", "am"), "Administrator") self.execute("bench --site {site} set-password Administrator test1") self.assertEqual(self.returncode, 0) self.assertEqual(check_password("Administrator", "test1"), "Administrator") # to release the lock taken by check_password - frappe.db.commit() + frappe.db.rollback() self.execute("bench --site {site} set-admin-password test2") self.assertEqual(self.returncode, 0) + frappe.db.rollback() self.assertEqual(check_password("Administrator", "test2"), "Administrator") - frappe.db.commit() + frappe.db.rollback() # Reset it back to original password original_password = frappe.conf.admin_password or "admin" self.execute("bench --site {site} set-admin-password %s" % original_password) self.assertEqual(self.returncode, 0) + frappe.db.rollback() self.assertEqual(check_password("Administrator", original_password), "Administrator") - frappe.db.commit() + frappe.db.rollback() @skipIf( not ( From c7edd7e57c712020151c355a105e9d41360be27c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 16 Jan 2023 18:00:10 +0530 Subject: [PATCH 082/407] chore: remove unnecessary assertion --- frappe/tests/test_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 66c78d825c..83901fd9c4 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -405,9 +405,9 @@ class TestCommands(BaseTestCommands): def test_set_password(self): from frappe.utils.password import check_password - self.assertEqual(check_password("Administrator", "am"), "Administrator") self.execute("bench --site {site} set-password Administrator test1") self.assertEqual(self.returncode, 0) + frappe.db.rollback() self.assertEqual(check_password("Administrator", "test1"), "Administrator") # to release the lock taken by check_password frappe.db.rollback() From 968648e1b68c50d47028141404917ccf1cd09004 Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 16 Jan 2023 18:24:06 +0530 Subject: [PATCH 083/407] test: Test if permission query via server script is applied on cached allowed reports --- frappe/tests/test_boot.py | 50 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_boot.py b/frappe/tests/test_boot.py index 0b688d6aee..7baebac140 100644 --- a/frappe/tests/test_boot.py +++ b/frappe/tests/test_boot.py @@ -1,5 +1,5 @@ import frappe -from frappe.boot import get_unseen_notes +from frappe.boot import get_unseen_notes, get_user_pages_or_reports from frappe.desk.doctype.note.note import mark_as_seen from frappe.tests.utils import FrappeTestCase @@ -26,3 +26,51 @@ class TestBootData(FrappeTestCase): mark_as_seen(note.name) unseen_notes = [d.title for d in get_unseen_notes()] self.assertListEqual(unseen_notes, []) + + def test_get_user_pages_or_reports_with_permission_query(self): + try: + # Create a ToDo custom report with admin user + frappe.set_user("Administrator") + frappe.get_doc( + { + "doctype": "Report", + "ref_doctype": "ToDo", + "report_name": "Test Admin Report", + "report_type": "Report Builder", + "is_standard": "No", + } + ).insert() + + # Add permission query such that each user can only see their own custom reports + frappe.get_doc( + dict( + doctype="Server Script", + name="test_report_permission_query", + script_type="Permission Query", + reference_doctype="Report", + script="""conditions = f"(`tabReport`.is_standard = 'Yes' or `tabReport`.owner = '{frappe.session.user}')" + """, + ) + ).insert() + + # Create a ToDo custom report with test user + frappe.set_user("test@example.com") + frappe.get_doc( + { + "doctype": "Report", + "ref_doctype": "ToDo", + "report_name": "Test User Report", + "report_type": "Report Builder", + "is_standard": "No", + } + ).insert() + + get_user_pages_or_reports("Report") + allowed_reports = frappe.cache().get_value("has_role:Report", user=frappe.session.user) + + # Test user must not see admin user's report + self.assertNotIn("Test Admin Report", allowed_reports) + self.assertIn("Test User Report", allowed_reports) + finally: + frappe.db.rollback() + frappe.set_user("Administrator") From 433115f62d6c7a62b57ae74faebf9d3c92c4ec52 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 16 Jan 2023 18:43:52 +0530 Subject: [PATCH 084/407] test: rollback test transaction after executing cmd (#19606) In command tests if connection is active then due to repeatable read isolation you will continue to read old data which might be modified by the command you're trying to test. It makes sense to end transaction after each command execution here. [skip ci] --- frappe/tests/test_commands.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 83901fd9c4..f8f3921440 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -143,6 +143,9 @@ class BaseTestCommands(FrappeTestCase): @classmethod def execute(self, command, kwargs=None): + # tests might have written to DB which wont be visible to commands until we end current transaction + frappe.db.commit() + site = {"site": frappe.local.site} cmd_input = None if kwargs: @@ -165,6 +168,9 @@ class BaseTestCommands(FrappeTestCase): self.stderr = clean(self._proc.stderr) self.returncode = clean(self._proc.returncode) + # Commands might have written to DB which wont be visible until we end current transaction + frappe.db.rollback() + @classmethod def setup_test_site(cls): cmd_config = { @@ -407,24 +413,17 @@ class TestCommands(BaseTestCommands): self.execute("bench --site {site} set-password Administrator test1") self.assertEqual(self.returncode, 0) - frappe.db.rollback() self.assertEqual(check_password("Administrator", "test1"), "Administrator") - # to release the lock taken by check_password - frappe.db.rollback() self.execute("bench --site {site} set-admin-password test2") self.assertEqual(self.returncode, 0) - frappe.db.rollback() self.assertEqual(check_password("Administrator", "test2"), "Administrator") - frappe.db.rollback() # Reset it back to original password original_password = frappe.conf.admin_password or "admin" self.execute("bench --site {site} set-admin-password %s" % original_password) self.assertEqual(self.returncode, 0) - frappe.db.rollback() self.assertEqual(check_password("Administrator", original_password), "Administrator") - frappe.db.rollback() @skipIf( not ( From f6a68062d9fe5e44ee31436bc5639cc7ed8e4caf Mon Sep 17 00:00:00 2001 From: marination Date: Mon, 16 Jan 2023 19:15:16 +0530 Subject: [PATCH 085/407] chore: Add comments to avoid incompatible queries with `_run_with_permission_query` --- frappe/boot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frappe/boot.py b/frappe/boot.py index bb8393e8dc..8585bddf90 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -174,6 +174,7 @@ def get_user_pages_or_reports(parent, cache=False): parentTable = DocType(parent) # get pages or reports set on custom role + # must end in a WHERE clause for `_run_with_permission_query` pages_with_custom_roles = ( frappe.qb.from_(customRole) .from_(hasRole) @@ -199,6 +200,7 @@ def get_user_pages_or_reports(parent, cache=False): .where(customRole[parent.lower()].isnotnull()) ) + # must end in a WHERE clause for `_run_with_permission_query` pages_with_standard_roles = ( frappe.qb.from_(hasRole) .from_(parentTable) @@ -228,6 +230,7 @@ def get_user_pages_or_reports(parent, cache=False): # pages with no role are allowed if parent == "Page": + # must end in a WHERE clause for `_run_with_permission_query` pages_with_no_roles = ( frappe.qb.from_(parentTable) .select(parentTable.name, parentTable.modified, *columns) From 75ae0fa2483688a2417f5ec4df3ccf2b96220312 Mon Sep 17 00:00:00 2001 From: Ritwik Puri Date: Tue, 17 Jan 2023 10:50:07 +0530 Subject: [PATCH 086/407] chore: remove unnecessary query condition from get_other_system_managers (#19611) --- frappe/core/doctype/user/user.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 7ba35f5181..a7e5cf7669 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -305,12 +305,10 @@ class User(Document): .from_(user_role_doctype) .select(user_doctype.name) .where(user_role_doctype.role == "System Manager") - .where(user_doctype.docstatus < 2) .where(user_doctype.enabled == 1) .where(user_role_doctype.parent == user_doctype.name) .where(user_role_doctype.parent.notin(["Administrator", self.name])) .limit(1) - .distinct() ).run() def get_fullname(self): From 53f81549078e3bdc3fcb5d29109eabc4457fd42c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 17 Jan 2023 10:55:54 +0530 Subject: [PATCH 087/407] chore(flake8): ignore B028 (#19612) [skip ci] --- .flake8 | 1 + 1 file changed, 1 insertion(+) diff --git a/.flake8 b/.flake8 index 2de7a154c9..e783fbbeb3 100644 --- a/.flake8 +++ b/.flake8 @@ -69,6 +69,7 @@ ignore = F841, E713, E712, + B028, max-line-length = 200 exclude=,test_*.py From 647ac83abf8d63c377c0c554f4eefeb4a58fd435 Mon Sep 17 00:00:00 2001 From: Ritwik Puri Date: Tue, 17 Jan 2023 11:44:04 +0530 Subject: [PATCH 088/407] chore(patch): send notification to system managers for resetup of oauth enabled email accounts (#19610) --- .../disable_email_accounts_with_oauth.py | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/frappe/patches/v14_0/disable_email_accounts_with_oauth.py b/frappe/patches/v14_0/disable_email_accounts_with_oauth.py index 27c322c60a..d620bf4e3b 100644 --- a/frappe/patches/v14_0/disable_email_accounts_with_oauth.py +++ b/frappe/patches/v14_0/disable_email_accounts_with_oauth.py @@ -1,15 +1,36 @@ -import click - import frappe +from frappe.desk.doctype.notification_log.notification_log import make_notification_logs def execute(): + if not frappe.get_value("Email Account", {"auth_method": "OAuth"}): + return + # Setting awaiting password to 1 for email accounts where Oauth is enabled. # This is done so that people can resetup their email accounts with connected app mechanism. - doctype = frappe.qb.DocType("Email Account") - frappe.qb.update(doctype).set(doctype.awaiting_password, 1).where(doctype.auth_mehtod == "OAuth") + frappe.db.set_value("Email Account", {"auth_method": "OAuth"}, "awaiting_password", 1) - click.secho( - "Email Accounts with auth method as OAuth have been disabled." - "Please re-setup your OAuth based email accounts with the connected app mechanism to re-enable them." - ) + message = "Email Accounts with auth method as OAuth have been disabled.\ + Please re-setup your OAuth based email accounts with the connected app mechanism to re-enable them." + + if sysmanagers := get_system_managers(): + make_notification_logs( + { + "type": "Alert", + "subject": frappe._(message), + }, + sysmanagers, + ) + + +def get_system_managers(): + user_doctype = frappe.qb.DocType("User").as_("user") + user_role_doctype = frappe.qb.DocType("Has Role").as_("user_role") + return ( + frappe.qb.from_(user_doctype) + .from_(user_role_doctype) + .select(user_doctype.email) + .where(user_role_doctype.role == "System Manager") + .where(user_doctype.enabled == 1) + .where(user_role_doctype.parent == user_doctype.name) + ).run(pluck=True) From 4bb5b10c7f32c37d913a625cca62f9c8982d7c1f Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 17 Jan 2023 12:06:39 +0530 Subject: [PATCH 089/407] fix(DX): better error msg for non-whitelisted methods (#19616) [skip ci] --- frappe/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index b4b44925ef..fae3b31c4a 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -770,7 +770,12 @@ def is_whitelisted(method): is_guest = session["user"] == "Guest" if method not in whitelisted or is_guest and method not in guest_methods: - throw(_("Not permitted"), PermissionError) + summary = _("You are not permitted to access this resource.") + detail = _("Function {0} is not whitelisted.").format( + bold(f"{method.__module__}.{method.__name__}") + ) + msg = f"
{summary}{detail}
" + throw(msg, PermissionError, title="Method Not Allowed") if is_guest and method not in xss_safe_methods: # strictly sanitize form_dict From ee17b221103907bcdfd194d48ba8fbbfea40c1c7 Mon Sep 17 00:00:00 2001 From: Faris Ansari Date: Tue, 17 Jan 2023 14:04:31 +0530 Subject: [PATCH 090/407] fix: only replace "tab" at the beginning --- frappe/database/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/database/utils.py b/frappe/database/utils.py index f89ba3c737..304fd72be6 100644 --- a/frappe/database/utils.py +++ b/frappe/database/utils.py @@ -35,8 +35,8 @@ def is_pypika_function_object(field: str) -> bool: def get_doctype_name(table_name: str) -> str: - if "tab" in table_name: - table_name = table_name.replace("tab", "") + if table_name.startswith(("tab", "`tab", '"tab')): + table_name = table_name.replace("tab", "", 1) table_name = table_name.replace("`", "") table_name = table_name.replace('"', "") return table_name From 6b1e98e0d66555ae790764b850034b7321fae062 Mon Sep 17 00:00:00 2001 From: Ritwik Puri Date: Tue, 17 Jan 2023 14:19:33 +0530 Subject: [PATCH 091/407] chore: reorder disable_email_accounts_with_oauth to post model sync (#19620) --- frappe/patches.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/patches.txt b/frappe/patches.txt index b345e7f106..7f7ab9bfe2 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -198,7 +198,6 @@ frappe.patches.v14_0.delete_payment_gateways frappe.patches.v15_0.remove_event_streaming frappe.patches.v15_0.remove_prepared_report_settings_from_system_settings frappe.patches.v15_0.copy_disable_prepared_report_to_prepared_report -frappe.patches.v14_0.disable_email_accounts_with_oauth [post_model_sync] frappe.patches.v14_0.drop_data_import_legacy @@ -221,3 +220,4 @@ frappe.patches.v14_0.add_manage_subscriptions_in_navbar_settings frappe.patches.v14_0.update_attachment_comment frappe.patches.v15_0.set_contact_full_name execute:frappe.delete_doc("Page", "activity", force=1) +frappe.patches.v14_0.disable_email_accounts_with_oauth From 551f58bde9efdad6895957e0d73e1b385b723964 Mon Sep 17 00:00:00 2001 From: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> Date: Tue, 17 Jan 2023 16:32:44 +0530 Subject: [PATCH 092/407] fix: Convert extension to lowercase before comparison --- frappe/core/doctype/file/file.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/file/file.js b/frappe/core/doctype/file/file.js index 4fd092a00b..159cf1ce39 100644 --- a/frappe/core/doctype/file/file.js +++ b/frappe/core/doctype/file/file.js @@ -24,6 +24,8 @@ frappe.ui.form.on("File", { preview_file: function (frm) { let $preview = ""; + let file_name = frm.doc.file_name.split("?")[0]; + let file_extension = file_name.split(".").pop()?.toLowerCase(); if (frappe.utils.is_image_file(frm.doc.file_url)) { $preview = $(`
@@ -40,7 +42,7 @@ frappe.ui.form.on("File", { ${__("Your browser does not support the video element.")}
`); - } else if (frm.doc.file_name.split("?")[0].endsWith(".pdf")) { + } else if (file_extension === "pdf") { $preview = $(`
`); - } else if (frm.doc.file_name.split("?")[0].endsWith(".mp3")) { + } else if (file_extension === "mp3") { $preview = $(`
{{ section.df.description }}
-
+
Date: Wed, 18 Jan 2023 12:53:32 +0100 Subject: [PATCH 100/407] refactor: address template --- .../address_template/address_template.jinja | 10 ++++ .../address_template/address_template.py | 48 +++++++------------ .../address_template/test_address_template.py | 46 +++++++++--------- 3 files changed, 49 insertions(+), 55 deletions(-) create mode 100644 frappe/contacts/doctype/address_template/address_template.jinja diff --git a/frappe/contacts/doctype/address_template/address_template.jinja b/frappe/contacts/doctype/address_template/address_template.jinja new file mode 100644 index 0000000000..65ea58eb21 --- /dev/null +++ b/frappe/contacts/doctype/address_template/address_template.jinja @@ -0,0 +1,10 @@ +{{ address_line1 }}
+{% if address_line2 %}{{ address_line2 }}
{% endif -%} +{{ city }}
+{% if state %}{{ state }}
{% endif -%} +{% if pincode %}{{ pincode }}
{% endif -%} +{{ country }}
+
+{% if phone %}{{ _("Phone") }}: {{ phone }}
{% endif -%} +{% if fax %}{{ _("Fax") }}: {{ fax }}
{% endif -%} +{% if email_id %}{{ _("Email") }}: {{ email_id }}
{% endif -%} diff --git a/frappe/contacts/doctype/address_template/address_template.py b/frappe/contacts/doctype/address_template/address_template.py index a8806b336b..a33115b105 100644 --- a/frappe/contacts/doctype/address_template/address_template.py +++ b/frappe/contacts/doctype/address_template/address_template.py @@ -4,52 +4,36 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cint from frappe.utils.jinja import validate_template class AddressTemplate(Document): def validate(self): + validate_template(self.template) + if not self.template: self.template = get_default_address_template() - self.defaults = frappe.db.get_values( - "Address Template", {"is_default": 1, "name": ("!=", self.name)} - ) - if not self.is_default: - if not self.defaults: - self.is_default = 1 - if cint(frappe.db.get_single_value("System Settings", "setup_complete")): - frappe.msgprint(_("Setting this Address Template as default as there is no other default")) - - validate_template(self.template) + if not self.is_default and not self._get_previous_default(): + self.is_default = 1 + if frappe.db.get_single_value("System Settings", "setup_complete"): + frappe.msgprint(_("Setting this Address Template as default as there is no other default")) def on_update(self): - if self.is_default and self.defaults: - for d in self.defaults: - frappe.db.set_value("Address Template", d[0], "is_default", 0) + if self.is_default and (previous_default := self._get_previous_default()): + frappe.db.set_value("Address Template", previous_default, "is_default", 0) def on_trash(self): if self.is_default: frappe.throw(_("Default Address Template cannot be deleted")) + def _get_previous_default(self) -> str | None: + return frappe.db.get_value("Address Template", {"is_default": 1, "name": ("!=", self.name)}) + @frappe.whitelist() -def get_default_address_template(): - """Get default address template (translated)""" - return ( - """{{ address_line1 }}
{% if address_line2 %}{{ address_line2 }}
{% endif -%}\ -{{ city }}
-{% if state %}{{ state }}
{% endif -%} -{% if pincode %}{{ pincode }}
{% endif -%} -{{ country }}
-{% if phone %}""" - + _("Phone") - + """: {{ phone }}
{% endif -%} -{% if fax %}""" - + _("Fax") - + """: {{ fax }}
{% endif -%} -{% if email_id %}""" - + _("Email") - + """: {{ email_id }}
{% endif -%}""" - ) +def get_default_address_template() -> str: + """Return the default address template.""" + from pathlib import Path + + return (Path(__file__).parent / "address_template.jinja").read_text() diff --git a/frappe/contacts/doctype/address_template/test_address_template.py b/frappe/contacts/doctype/address_template/test_address_template.py index ee45ce98f8..c3c5b544d6 100644 --- a/frappe/contacts/doctype/address_template/test_address_template.py +++ b/frappe/contacts/doctype/address_template/test_address_template.py @@ -1,39 +1,39 @@ # Copyright (c) 2015, Frappe Technologies and Contributors # License: MIT. See LICENSE import frappe +from frappe.contacts.doctype.address_template.address_template import get_default_address_template from frappe.tests.utils import FrappeTestCase +from frappe.utils.jinja import validate_template class TestAddressTemplate(FrappeTestCase): - def setUp(self): - self.make_default_address_template() + def setUp(self) -> None: + frappe.db.delete("Address Template", {"country": "India"}) + frappe.db.delete("Address Template", {"country": "Brazil"}) + + def test_default_address_template(self): + validate_template(get_default_address_template()) def test_default_is_unset(self): - a = frappe.get_doc("Address Template", "India") - a.is_default = 1 - a.save() + frappe.get_doc({"doctype": "Address Template", "country": "India", "is_default": 1}).insert() - b = frappe.get_doc("Address Template", "Brazil") - b.is_default = 1 - b.save() + self.assertEqual(frappe.db.get_value("Address Template", "India", "is_default"), 1) + + frappe.get_doc({"doctype": "Address Template", "country": "Brazil", "is_default": 1}).insert() self.assertEqual(frappe.db.get_value("Address Template", "India", "is_default"), 0) + self.assertEqual(frappe.db.get_value("Address Template", "Brazil", "is_default"), 1) - def tearDown(self): - a = frappe.get_doc("Address Template", "India") - a.is_default = 1 - a.save() + def test_delete_address_template(self): + india = frappe.get_doc( + {"doctype": "Address Template", "country": "India", "is_default": 0} + ).insert() - @classmethod - def make_default_address_template(self): - template = """{{ address_line1 }}
{% if address_line2 %}{{ address_line2 }}
{% endif -%}{{ city }}
{% if state %}{{ state }}
{% endif -%}{% if pincode %}{{ pincode }}
{% endif -%}{{ country }}
{% if phone %}Phone: {{ phone }}
{% endif -%}{% if fax %}Fax: {{ fax }}
{% endif -%}{% if email_id %}Email: {{ email_id }}
{% endif -%}""" + brazil = frappe.get_doc( + {"doctype": "Address Template", "country": "Brazil", "is_default": 1} + ).insert() - if not frappe.db.exists("Address Template", "India"): - frappe.get_doc( - {"doctype": "Address Template", "country": "India", "is_default": 1, "template": template} - ).insert() + india.reload() # might have been modified by the second template + india.delete() # should not raise an error - if not frappe.db.exists("Address Template", "Brazil"): - frappe.get_doc( - {"doctype": "Address Template", "country": "Brazil", "template": template} - ).insert() + self.assertRaises(frappe.ValidationError, brazil.delete) From 1796cae6bf8294741ffa86e8d4cd7899948b9939 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 18 Jan 2023 17:53:04 +0530 Subject: [PATCH 101/407] feat: let users modify hook resolution order Since hook resolution depends on the order in which apps were installed on site, it should be made configurable as escape hatch in case a different resolution order is desired. Keep in mind that changing order affects every hook, page, customization so you can't pick and choose priority for individual hooks as of now. Separate proposals are welcome for such configurabilty. --- .../installed_applications.js | 60 ++++++++++++++++++- .../installed_applications.py | 43 +++++++++++++ .../test_installed_applications.py | 12 +++- 3 files changed, 111 insertions(+), 4 deletions(-) diff --git a/frappe/core/doctype/installed_applications/installed_applications.js b/frappe/core/doctype/installed_applications/installed_applications.js index 223c028e7a..71c54a749a 100644 --- a/frappe/core/doctype/installed_applications/installed_applications.js +++ b/frappe/core/doctype/installed_applications/installed_applications.js @@ -2,6 +2,62 @@ // For license information, please see license.txt frappe.ui.form.on("Installed Applications", { - // refresh: function(frm) { - // } + refresh: function (frm) { + frm.add_custom_button(__("Update Hooks Resolution Order"), () => { + frm.trigger("show_update_order_dialog"); + }); + }, + + show_update_order_dialog() { + const dialog = new frappe.ui.Dialog({ + title: __("Update Hooks Resolution Order"), + fields: [ + { + fieldname: "apps", + fieldtype: "Table", + label: __("Installed Apps"), + cannot_add_rows: true, + cannot_delete_rows: true, + in_place_edit: true, + data: [], + fields: [ + { + fieldtype: "Data", + fieldname: "app_name", + label: __("App Name"), + in_list_view: 1, + read_only: 1, + }, + ], + }, + ], + primary_action: function () { + const new_order = this.get_values()["apps"].map((row) => row.app_name); + frappe.call({ + method: "frappe.core.doctype.installed_applications.installed_applications.update_installed_apps_order", + freeze: true, + args: { + new_order: new_order, + }, + }); + this.hide(); + }, + primary_action_label: __("Update Order"), + }); + + frappe + .xcall( + "frappe.core.doctype.installed_applications.installed_applications.get_installed_app_order" + ) + .then((data) => { + data.forEach((app) => { + dialog.fields_dict.apps.df.data.push({ + app_name: app, + }); + }); + + dialog.fields_dict.apps.grid.refresh(); + dialog.show(); + }); + }, }); diff --git a/frappe/core/doctype/installed_applications/installed_applications.py b/frappe/core/doctype/installed_applications/installed_applications.py index 07b20db153..f92920e68c 100644 --- a/frappe/core/doctype/installed_applications/installed_applications.py +++ b/frappe/core/doctype/installed_applications/installed_applications.py @@ -1,10 +1,17 @@ # Copyright (c) 2020, Frappe Technologies and contributors # License: MIT. See LICENSE +import json + import frappe +from frappe import _ from frappe.model.document import Document +class InvalidAppOrder(frappe.ValidationError): + pass + + class InstalledApplications(Document): def update_versions(self): self.delete_key("installed_applications") @@ -18,3 +25,39 @@ class InstalledApplications(Document): }, ) self.save() + + +@frappe.whitelist() +def update_installed_apps_order(new_order: list[str] | str): + """Change the ordering of `installed_apps` global + + This list is used to resolve hooks and by default it's order of installation on site. + + Sometimes it might not be the ordering you want, so thie function is provided to override it. + """ + frappe.only_for("System Manager") + + if isinstance(new_order, str): + new_order = json.loads(new_order) + + frappe.local.request_cache and frappe.local.request_cache.clear() + existing_order = frappe.get_installed_apps(_ensure_on_bench=True) + + if set(existing_order) != set(new_order) or not isinstance(new_order, list): + frappe.throw( + _("You are only allowed to update order, do not remove or add apps."), exc=InvalidAppOrder + ) + + # Ensure frappe is always first regardless of user's preference. + if "frappe" in new_order: + new_order.remove("frappe") + new_order.insert(0, "frappe") + + frappe.db.set_global("installed_apps", json.dumps(new_order)) + + +@frappe.whitelist() +def get_installed_app_order() -> list[str]: + frappe.only_for("System Manager") + + return frappe.get_installed_apps(_ensure_on_bench=True) diff --git a/frappe/core/doctype/installed_applications/test_installed_applications.py b/frappe/core/doctype/installed_applications/test_installed_applications.py index 854433ce40..1ee1c99b86 100644 --- a/frappe/core/doctype/installed_applications/test_installed_applications.py +++ b/frappe/core/doctype/installed_applications/test_installed_applications.py @@ -1,8 +1,16 @@ # Copyright (c) 2020, Frappe Technologies and Contributors # License: MIT. See LICENSE -# import frappe + +import frappe +from frappe.core.doctype.installed_applications.installed_applications import ( + InvalidAppOrder, + update_installed_apps_order, +) from frappe.tests.utils import FrappeTestCase class TestInstalledApplications(FrappeTestCase): - pass + def test_order_change(self): + update_installed_apps_order(["frappe"]) + self.assertRaises(InvalidAppOrder, update_installed_apps_order, []) + self.assertRaises(InvalidAppOrder, update_installed_apps_order, ["frappe", "deepmind"]) From c14379ce5c316714890e0473bdf7c385a70baff0 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 18 Jan 2023 19:16:09 +0530 Subject: [PATCH 102/407] fix: log changes made to installed_apps order --- .../installed_applications/installed_applications.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frappe/core/doctype/installed_applications/installed_applications.py b/frappe/core/doctype/installed_applications/installed_applications.py index f92920e68c..e3f63e8bb2 100644 --- a/frappe/core/doctype/installed_applications/installed_applications.py +++ b/frappe/core/doctype/installed_applications/installed_applications.py @@ -55,6 +55,18 @@ def update_installed_apps_order(new_order: list[str] | str): frappe.db.set_global("installed_apps", json.dumps(new_order)) + _create_version_log_for_change(existing_order, new_order) + + +def _create_version_log_for_change(old, new): + version = frappe.new_doc("Version") + version.ref_doctype = "DefaultValue" + version.docname = "installed_apps" + version.data = frappe.as_json({"changed": [["current", json.dumps(old), json.dumps(new)]]}) + version.flags.ignore_links = True # This is a fake doctype + version.flags.ignore_permissions = True + version.insert() + @frappe.whitelist() def get_installed_app_order() -> list[str]: From 4bc69031647dc52dd03abd577539539375d552eb Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 18 Jan 2023 20:51:11 +0530 Subject: [PATCH 103/407] fix: on duplicate of standard field create a custom field in customize form --- frappe/public/js/form_builder/components/Field.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frappe/public/js/form_builder/components/Field.vue b/frappe/public/js/form_builder/components/Field.vue index 5a7ce5626f..58c2d85b3b 100644 --- a/frappe/public/js/form_builder/components/Field.vue +++ b/frappe/public/js/form_builder/components/Field.vue @@ -32,6 +32,10 @@ function move_fields_to_column() { function duplicate_field() { let duplicate_field = clone_field(props.field); + if (store.is_customize_form) { + duplicate_field.df.is_custom_field = 1; + } + if (duplicate_field.df.label) { duplicate_field.df.label = duplicate_field.df.label + " Copy"; } From 986cc8d634d519bcf021194009f17cbd47c57912 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 18 Jan 2023 21:30:17 +0530 Subject: [PATCH 104/407] fix: make duplicated field as unsaved field reset creation, modified, modified_by, owner --- frappe/public/js/form_builder/components/Field.vue | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frappe/public/js/form_builder/components/Field.vue b/frappe/public/js/form_builder/components/Field.vue index 58c2d85b3b..cf3e21c310 100644 --- a/frappe/public/js/form_builder/components/Field.vue +++ b/frappe/public/js/form_builder/components/Field.vue @@ -40,6 +40,13 @@ function duplicate_field() { duplicate_field.df.label = duplicate_field.df.label + " Copy"; } duplicate_field.df.fieldname = ""; + duplicate_field.df.__islocal = 1; + duplicate_field.df.__unsaved = 1; + duplicate_field.df.owner = frappe.session.user; + + delete duplicate_field.df.creation; + delete duplicate_field.df.modified; + delete duplicate_field.df.modified_by; // push duplicate_field after props.field in the same column let index = props.column.fields.indexOf(props.field); From 30134a2cc98ce99f0303005c5116a56e7909cb49 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 18 Jan 2023 22:10:55 +0530 Subject: [PATCH 105/407] fix: Correct standard docfield types - `creation` and `modified` are timestamps not dates. - `modified_by` is similar to `owner` so not sure why we can't have this render as link as well --- frappe/public/js/frappe/model/model.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/model/model.js b/frappe/public/js/frappe/model/model.js index 18acf00ad3..b835989c07 100644 --- a/frappe/public/js/frappe/model/model.js +++ b/frappe/public/js/frappe/model/model.js @@ -112,9 +112,14 @@ $.extend(frappe.model, { { fieldname: "name", fieldtype: "Link", label: __("ID") }, { fieldname: "owner", fieldtype: "Link", label: __("Created By"), options: "User" }, { fieldname: "idx", fieldtype: "Int", label: __("Index") }, - { fieldname: "creation", fieldtype: "Date", label: __("Created On") }, - { fieldname: "modified", fieldtype: "Date", label: __("Last Updated On") }, - { fieldname: "modified_by", fieldtype: "Data", label: __("Last Updated By") }, + { fieldname: "creation", fieldtype: "Datetime", label: __("Created On") }, + { fieldname: "modified", fieldtype: "Datetime", label: __("Last Updated On") }, + { + fieldname: "modified_by", + fieldtype: "Link", + label: __("Last Updated By"), + options: "User", + }, { fieldname: "_user_tags", fieldtype: "Data", label: __("Tags") }, { fieldname: "_liked_by", fieldtype: "Data", label: __("Liked By") }, { fieldname: "_comments", fieldtype: "Text", label: __("Comments") }, From 96afc5ebd99f31f4ea584288425e02ec8e0bad4c Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 18 Jan 2023 22:13:22 +0530 Subject: [PATCH 106/407] fix: correct invalid filters - Datetime - equality doesn't make sense because of milliseconds. A separate operator for "date" part can be useful here maybe. - Code - data like filter and remove comparison operators. - Phone - treat like Data - Barcode - treat like data - attach - treat like data - attach image - treat like data - rating - remove invalid operators - password - LOL --- frappe/public/js/frappe/ui/filters/filter.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/ui/filters/filter.js b/frappe/public/js/frappe/ui/filters/filter.js index 261b1dd5ba..96c4fd712d 100644 --- a/frappe/public/js/frappe/ui/filters/filter.js +++ b/frappe/public/js/frappe/ui/filters/filter.js @@ -39,13 +39,16 @@ frappe.ui.Filter = class { this.invalid_condition_map = { Date: ["like", "not like"], - Datetime: ["like", "not like"], + Datetime: ["like", "not like", "in", "not in", "=", "!="], Data: ["Between", "Timespan"], Select: ["like", "not like", "Between", "Timespan"], Link: ["Between", "Timespan", ">", "<", ">=", "<="], Currency: ["Between", "Timespan"], Color: ["Between", "Timespan"], Check: this.conditions.map((c) => c[0]).filter((c) => c !== "="), + Code: ["Between", "Timespan", ">", "<", ">=", "<=", "in", "not in"], + Password: ["Between", "Timespan", ">", "<", ">=", "<=", "in", "not in"], + Rating: ["like", "not like", "Between", "in", "not in", "Timespan"], }; } @@ -497,10 +500,14 @@ frappe.ui.filter_utils = { "Small Text", "Text Editor", "Code", + "Attach", + "Attach Image", "Markdown Editor", "HTML Editor", "Tag", + "Phone", "Comments", + "Barcode", "Dynamic Link", "Read Only", "Assign", From b3f9e69a6e5749bf15af190e951a200f28777b52 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 18 Jan 2023 22:49:05 +0530 Subject: [PATCH 107/407] fix: use frappe.desk.form.save.savedocs instead of frappe.client.save for saving --- frappe/public/js/form_builder/store.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/form_builder/store.js b/frappe/public/js/form_builder/store.js index 314e8f5ed7..246956dc94 100644 --- a/frappe/public/js/form_builder/store.js +++ b/frappe/public/js/form_builder/store.js @@ -182,8 +182,10 @@ export const useStore = defineStore("form-builder-store", { } else { this.doc.fields = this.get_updated_fields(); this.validate_fields(this.doc.fields, this.doc.istable); - await frappe.call("frappe.client.save", { doc: this.doc }); - frappe.toast("Fields Table Updated"); + await frappe.call({ + method: "frappe.desk.form.save.savedocs", + args: { doc: this.doc, action: "Save" }, + }); } this.fetch(); } catch (e) { From 3b2cfcad2c9b7073e969302056c804498f6e7804 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 18 Jan 2023 23:51:52 +0530 Subject: [PATCH 108/407] fix: ask if need to delete tab/section/column with or without children --- .../js/form_builder/components/Column.vue | 67 +++++++++++++------ .../js/form_builder/components/Section.vue | 45 +++++++++---- .../js/form_builder/components/Tabs.vue | 63 +++++++++-------- frappe/public/js/form_builder/utils.js | 25 +++++++ 4 files changed, 139 insertions(+), 61 deletions(-) diff --git a/frappe/public/js/form_builder/components/Column.vue b/frappe/public/js/form_builder/components/Column.vue index a8f1f84118..a9c4bf0ea1 100644 --- a/frappe/public/js/form_builder/components/Column.vue +++ b/frappe/public/js/form_builder/components/Column.vue @@ -4,7 +4,7 @@ import Field from "./Field.vue"; import EditableInput from "./EditableInput.vue"; import { ref } from "vue"; import { useStore } from "../store"; -import { move_children_to_parent } from "../utils"; +import { move_children_to_parent, confirm_dialog } from "../utils"; let props = defineProps(["section", "column"]); let store = useStore(); @@ -24,32 +24,61 @@ function remove_column() { if (store.is_customize_form && props.column.df.is_custom_field == 0) { frappe.msgprint(__("Cannot delete standard field. You can hide it if you want")); throw "cannot delete standard field"; + } else if (props.column.fields.length == 0 || store.has_standard_field(props.column)) { + delete_column(); + } else { + confirm_dialog( + __("Delete Column", null, "Title of confirmation dialog"), + __("Are you sure you want to delete the column? All the fields in the column will be moved to the previous column.", null, "Confirmation dialog message"), + () => delete_column(), + __("Delete column", null, "Button text"), + () => delete_column(true), + __("Delete entire column with fields", null, "Button text") + ); } +} +function delete_column(with_children) { // move all fields to previous column let columns = props.section.columns; let index = columns.indexOf(props.column); - if (index > 0) { - let prev_column = columns[index - 1]; - prev_column.fields = [...prev_column.fields, ...props.column.fields]; - } else { - if (props.column.fields.length != 0) { - // create a new column if current column has fields and push fields to it - columns.unshift({ - df: store.get_df("Column Break"), - fields: props.column.fields, - is_first: true, - }); - index++; + if (with_children && index == 0 && columns.length == 1) { + if (props.column.fields.length == 0) { + frappe.msgprint(__("Section must have at least one column")); + throw "section must have at least one column"; + } + + columns.unshift({ + df: store.get_df("Column Break"), + fields: [], + is_first: true, + }); + index++; + } + + if (!with_children) { + if (index > 0) { + let prev_column = columns[index - 1]; + prev_column.fields = [...prev_column.fields, ...props.column.fields]; } else { - // set next column as first column - let next_column = columns[index + 1]; - if (next_column) { - next_column.is_first = true; + if (props.column.fields.length == 0) { + // set next column as first column + let next_column = columns[index + 1]; + if (next_column) { + next_column.is_first = true; + } else { + frappe.msgprint(__("Section must have at least one column")); + throw "section must have at least one column"; + } } else { - frappe.msgprint(__("Section must have at least one column")); - throw "section must have at least one column"; + // create a new column if current column has fields and push fields to it + columns.unshift({ + df: store.get_df("Column Break"), + fields: props.column.fields, + is_first: true, + }); + index++; } } } diff --git a/frappe/public/js/form_builder/components/Section.vue b/frappe/public/js/form_builder/components/Section.vue index f1419b7c89..4624c72a38 100644 --- a/frappe/public/js/form_builder/components/Section.vue +++ b/frappe/public/js/form_builder/components/Section.vue @@ -4,7 +4,7 @@ import Column from "./Column.vue"; import EditableInput from "./EditableInput.vue"; import { ref } from "vue"; import { useStore } from "../store"; -import { section_boilerplate, move_children_to_parent } from "../utils"; +import { section_boilerplate, move_children_to_parent, confirm_dialog } from "../utils"; let props = defineProps(["tab", "section"]); let store = useStore(); @@ -27,25 +27,42 @@ function remove_section() { if (store.is_customize_form && props.section.df.is_custom_field == 0) { frappe.msgprint(__("Cannot delete standard field. You can hide it if you want")); throw "cannot delete standard field"; + } else if (store.has_standard_field(props.section)) { + delete_section(); + } else if (is_section_empty()) { + delete_section(true); + } else { + confirm_dialog( + __("Delete Section", null, "Title of confirmation dialog"), + __("Are you sure you want to delete the section? All the columns along with fields in the section will be moved to the previous section.", null, "Confirmation dialog message"), + () => delete_section(), + __("Delete section", null, "Button text"), + () => delete_section(true), + __("Delete entire section with columns", null, "Button text") + ); } +} +function delete_section(with_children) { let sections = props.tab.sections; let index = sections.indexOf(props.section); - if (index > 0) { - let prev_section = sections[index - 1]; - if (!is_section_empty()) { - // move all columns from current section to previous section - prev_section.columns = [...prev_section.columns, ...props.section.columns]; + if (!with_children) { + if (index > 0) { + let prev_section = sections[index - 1]; + if (!is_section_empty()) { + // move all columns from current section to previous section + prev_section.columns = [...prev_section.columns, ...props.section.columns]; + } + } else if (index == 0 && !is_section_empty()) { + // create a new section and push columns to it + sections.unshift({ + df: store.get_df("Section Break"), + columns: props.section.columns, + is_first: true, + }); + index++; } - } else if (index == 0 && !is_section_empty()) { - // create a new section and push columns to it - sections.unshift({ - df: store.get_df("Section Break"), - columns: props.section.columns, - is_first: true, - }); - index++; } // remove section diff --git a/frappe/public/js/form_builder/components/Tabs.vue b/frappe/public/js/form_builder/components/Tabs.vue index d2ef939f80..625ca38745 100644 --- a/frappe/public/js/form_builder/components/Tabs.vue +++ b/frappe/public/js/form_builder/components/Tabs.vue @@ -3,7 +3,7 @@ import Section from "./Section.vue"; import EditableInput from "./EditableInput.vue"; import draggable from "vuedraggable"; import { useStore } from "../store"; -import { section_boilerplate } from "../utils"; +import { section_boilerplate, confirm_dialog } from "../utils"; import { ref, computed, nextTick } from "vue"; let store = useStore(); @@ -51,44 +51,51 @@ function add_new_section() { function is_current_tab_empty() { // check if sections have columns and it contains fields - return !store.current_tab.sections.some(section => { - // if section doesnt have fields remove the section - let has_fields = section.columns.some(column => column.fields.length); - - if (!has_fields) { - // remove section if empty - let index = store.current_tab.sections.indexOf(section); - store.current_tab.sections.splice(index, 1); - has_fields = true; - } - - return has_fields; - }); + return !store.current_tab.sections.some( + section => section.columns.some(column => column.fields.length) + ); } function remove_tab() { if (store.is_customize_form && store.current_tab.df.is_custom_field == 0) { frappe.msgprint(__("Cannot delete standard field. You can hide it if you want")); throw "cannot delete standard field"; + } else if (store.has_standard_field(store.current_tab)) { + delete_tab(); + } else if (is_current_tab_empty()) { + delete_tab(true); + } else { + confirm_dialog( + __("Delete Tab", null, "Title of confirmation dialog"), + __("Are you sure you want to delete the tab? All the sections along with fields in the tab will be moved to the previous tab.", null, "Confirmation dialog message"), + () => delete_tab(), + __("Delete tab", null, "Button text"), + () => delete_tab(true), + __("Delete entire tab with sections", null, "Button text") + ); } +} +function delete_tab(with_children) { let tabs = layout.value.tabs; let index = tabs.indexOf(store.current_tab); - if (index > 0) { - let prev_tab = tabs[index - 1]; - if (!is_current_tab_empty()) { - // move all sections from current tab to previous tab - prev_tab.sections = [...prev_tab.sections, ...store.current_tab.sections]; + if (!with_children) { + if (index > 0) { + let prev_tab = tabs[index - 1]; + if (!is_current_tab_empty()) { + // move all sections from current tab to previous tab + prev_tab.sections = [...prev_tab.sections, ...store.current_tab.sections]; + } + } else { + // create a new tab and push sections to it + tabs.unshift({ + df: store.get_df("Tab Break", "", __("Details")), + sections: store.current_tab.sections, + is_first: true, + }); + index++; } - } else { - // create a new tab and push sections to it - tabs.unshift({ - df: store.get_df("Tab Break", "", __("Details")), - sections: store.current_tab.sections, - is_first: true, - }); - index++; } // remove tab @@ -185,7 +192,7 @@ function remove_tab() {
-
{{ __("Drag & Drop a section here") }}
+
{{ __("Drag & Drop a section here from another tab") }}
{{ __("OR") }}
`); this.setup_new_doc_event(); - this.list_sidebar && this.list_sidebar.reload_stats(); + if (this.list_view_settings && !this.list_view_settings.disable_sidebar_stats) { + this.list_sidebar && this.list_sidebar.reload_stats(); + } this.toggle_paging && this.$paging_area.toggle(true); } From 04113281015c2e39755682a785da721277272d06 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Fri, 20 Jan 2023 18:29:10 +0530 Subject: [PATCH 121/407] fix: use document title instead of name in subject --- frappe/templates/includes/comments/comments.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frappe/templates/includes/comments/comments.py b/frappe/templates/includes/comments/comments.py index 44963051ca..3a056761f3 100644 --- a/frappe/templates/includes/comments/comments.py +++ b/frappe/templates/includes/comments/comments.py @@ -54,9 +54,12 @@ def add_comment(comment, comment_email, comment_by, reference_doctype, reference pass else: # notify creator + creator_email = frappe.db.get_value("User", doc.owner, "email") or doc.owner + subject = _("New Comment on {0}: {1}").format(doc.doctype, doc.get_title()) + frappe.sendmail( - recipients=frappe.db.get_value("User", doc.owner, "email") or doc.owner, - subject=_("New Comment on {0}: {1}").format(doc.doctype, doc.name), + recipients=creator_email, + subject=subject, message=content, reference_doctype=doc.doctype, reference_name=doc.name, From e8209b5dce01ba5b28d4aab92e3d81f4849c9ee0 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 20 Jan 2023 19:03:56 +0530 Subject: [PATCH 122/407] fix: invalid "empty form" message (#19696) --- frappe/public/js/frappe/form/form.js | 4 ---- frappe/public/js/frappe/form/layout.js | 11 ----------- 2 files changed, 15 deletions(-) diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 75a1def1dc..48384fbb9b 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -621,10 +621,6 @@ frappe.ui.form.Form = class FrappeForm { this.$wrapper.trigger("render_complete"); - if (!this.hidden) { - this.layout.show_empty_form_message(); - } - frappe.after_ajax(() => { $(document).ready(() => { this.scroll_to_element(); diff --git a/frappe/public/js/frappe/form/layout.js b/frappe/public/js/frappe/form/layout.js index 2f400c8313..a01b414a0c 100644 --- a/frappe/public/js/frappe/form/layout.js +++ b/frappe/public/js/frappe/form/layout.js @@ -52,17 +52,6 @@ frappe.ui.form.Layout = class Layout { this.setup_events(); } - show_empty_form_message() { - if ( - !( - this.wrapper.find(".frappe-control:visible").length || - this.wrapper.find(".section-head.collapsed").length - ) - ) { - this.show_message(__("This form does not have any input")); - } - } - get_doctype_fields() { let fields = [this.get_new_name_field()]; if (this.doctype_layout) { From e1ed1e9899dbfb2a5a8faffbff5f87752d60dc0d Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 20 Jan 2023 19:05:51 +0530 Subject: [PATCH 123/407] fix: rate limit newsletter subscriptions (#19690) --- frappe/email/doctype/newsletter/newsletter.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py index 30d51c4c03..4745a8f1ca 100644 --- a/frappe/email/doctype/newsletter/newsletter.py +++ b/frappe/email/doctype/newsletter/newsletter.py @@ -6,6 +6,7 @@ import frappe import frappe.utils from frappe import _ from frappe.email.doctype.email_group.email_group import add_subscribers +from frappe.rate_limiter import rate_limit from frappe.utils.safe_exec import is_job_queued from frappe.utils.verified_command import get_signed_params, verify_request from frappe.website.website_generator import WebsiteGenerator @@ -227,7 +228,6 @@ class Newsletter(WebsiteGenerator): ) -@frappe.whitelist(allow_guest=True) def confirmed_unsubscribe(email, group): """unsubscribe the email(user) from the mailing list(email_group)""" frappe.flags.ignore_permissions = True @@ -238,9 +238,13 @@ def confirmed_unsubscribe(email, group): @frappe.whitelist(allow_guest=True) -def subscribe(email, email_group=_("Website")): # noqa +@rate_limit(limit=10, seconds=60 * 60) +def subscribe(email, email_group=None): # noqa """API endpoint to subscribe an email to a particular email group. Triggers a confirmation email.""" + if email_group is None: + email_group = _("Website") + # build subscription confirmation URL api_endpoint = frappe.utils.get_url( "/api/method/frappe.email.doctype.newsletter.newsletter.confirm_subscription" From e2b3dd60c28ba899a717fdc501d995befb47a4b8 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Fri, 20 Jan 2023 19:12:21 +0530 Subject: [PATCH 124/407] fix: Indicator contrast to make it more readable --- frappe/public/scss/common/css_variables.scss | 12 ++++++------ frappe/public/scss/desk/dark.scss | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frappe/public/scss/common/css_variables.scss b/frappe/public/scss/common/css_variables.scss index aa40c9a17f..42d105e60e 100644 --- a/frappe/public/scss/common/css_variables.scss +++ b/frappe/public/scss/common/css_variables.scss @@ -188,15 +188,15 @@ $input-height: 28px !default; --text-on-dark-blue: var(--blue-800); --text-on-green: var(--dark-green-700); --text-on-yellow: var(--yellow-700); - --text-on-orange: var(--orange-600); + --text-on-orange: var(--orange-700); --text-on-red: var(--red-600); - --text-on-gray: var(--gray-600); - --text-on-grey: var(--gray-600); - --text-on-darkgrey: var(--gray-700); - --text-on-dark-gray: var(--gray-700); + --text-on-gray: var(--gray-700); + --text-on-grey: var(--gray-700); + --text-on-darkgrey: var(--gray-800); + --text-on-dark-gray: var(--gray-800); --text-on-light-gray: var(--gray-800); --text-on-purple: var(--purple-700); - --text-on-pink: var(--pink-600); + --text-on-pink: var(--pink-700); --text-on-cyan: var(--cyan-800); // alert colors diff --git a/frappe/public/scss/desk/dark.scss b/frappe/public/scss/desk/dark.scss index 2d37fc3c4b..5970fb509c 100644 --- a/frappe/public/scss/desk/dark.scss +++ b/frappe/public/scss/desk/dark.scss @@ -47,22 +47,22 @@ // Background Text Color Pairs --bg-blue: var(--blue-600); - --bg-light-blue: var(--blue-400); + --bg-light-blue: var(--blue-600); --bg-dark-blue: var(--blue-900); - --bg-green: var(--dark-green-500); - --bg-yellow: var(--yellow-500); - --bg-orange: var(--orange-500); - --bg-red: var(--red-500); + --bg-green: var(--green-800); + --bg-yellow: var(--yellow-700); + --bg-orange: var(--orange-700); + --bg-red: var(--red-600); --bg-gray: var(--gray-600); --bg-grey: var(--gray-600); --bg-darkgrey: var(--gray-800); --bg-dark-gray: var(--gray-800); --bg-light-gray: var(--gray-700); --bg-dark-gray: var(--gray-300); - --bg-purple: var(--purple-600); + --bg-purple: var(--purple-700); --text-on-blue: var(--blue-50); - --text-on-light-blue: var(--blue-100); + --text-on-light-blue: var(--blue-50); --text-on-dark-blue: var(--blue-300); --text-on-green: var(--dark-green-50); --text-on-yellow: var(--yellow-50); From ee3a6fab7d61fd2d35d77c2a2be82baeaccef263 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Fri, 20 Jan 2023 19:24:21 +0530 Subject: [PATCH 125/407] fix: Add cyan and pink indicator color for dark theme - fix gray indicator colors on dark... make them lighter to make it more consistent and visible --- frappe/public/scss/desk/dark.scss | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/frappe/public/scss/desk/dark.scss b/frappe/public/scss/desk/dark.scss index 5970fb509c..e1f210c440 100644 --- a/frappe/public/scss/desk/dark.scss +++ b/frappe/public/scss/desk/dark.scss @@ -53,13 +53,14 @@ --bg-yellow: var(--yellow-700); --bg-orange: var(--orange-700); --bg-red: var(--red-600); - --bg-gray: var(--gray-600); - --bg-grey: var(--gray-600); - --bg-darkgrey: var(--gray-800); - --bg-dark-gray: var(--gray-800); + --bg-gray: var(--gray-400); + --bg-grey: var(--gray-400); + --bg-darkgrey: var(--gray-600); + --bg-dark-gray: var(--gray-600); --bg-light-gray: var(--gray-700); - --bg-dark-gray: var(--gray-300); --bg-purple: var(--purple-700); + --bg-pink: var(--pink-700); + --bg-cyan: var(--cyan-800); --text-on-blue: var(--blue-50); --text-on-light-blue: var(--blue-50); @@ -68,12 +69,14 @@ --text-on-yellow: var(--yellow-50); --text-on-orange: var(--orange-100); --text-on-red: var(--red-50); - --text-on-gray: var(--gray-200); - --text-on-grey: var(--gray-200); + --text-on-gray: var(--gray-50); + --text-on-grey: var(--gray-50); --text-on-darkgrey: var(--gray-200); --text-on-dark-gray: var(--gray-200); --text-on-light-gray: var(--gray-100); --text-on-purple: var(--purple-100); + --text-on-pink: var(--pink-100); + --text-on-cyan: var(--cyan-100); // alert colors --alert-text-danger: var(--red-300); From 9efe84a644ab253820069d0b1511939cd5bbea4a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 20 Jan 2023 19:22:34 +0530 Subject: [PATCH 126/407] fix(reportview): Remove aggregate_on_field added in fields Essentially reverts changes added via https://github.com/frappe/frappe/pull/14424 but works just the same without the additional column :thonk: The added column's data in mariadb was essentially garbage data. Wasn't meaningful from what I could tell - couldn't play well with postgres either --- frappe/desk/reportview.py | 10 +++------- frappe/tests/test_db_query.py | 4 +--- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index fcecdf94b0..a0b78a4035 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -150,13 +150,9 @@ def setup_group_by(data): frappe.throw(_("Invalid aggregate function")) if frappe.db.has_column(data.aggregate_on_doctype, data.aggregate_on_field): - column = f"`tab{data.aggregate_on_doctype}`.`{data.aggregate_on_field}`" - aggregate_function = data.aggregate_function - - data.fields.append(f"{aggregate_function}({column}) AS _aggregate_column") - if data.aggregate_on_field: - data.fields.append(column) - data.group_by += f", {column}" + data.fields.append( + f"{data.aggregate_function}(`tab{data.aggregate_on_doctype}`.`{data.aggregate_on_field}`) AS _aggregate_column" + ) else: raise_invalid_field(data.aggregate_on_field) diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 16ee015169..dba301109c 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -908,9 +908,7 @@ class TestReportview(FrappeTestCase): ) response = execute_cmd("frappe.desk.reportview.get") - self.assertListEqual( - response["keys"], ["field_label", "field_name", "_aggregate_column", "columns"] - ) + self.assertListEqual(response["keys"], ["field_label", "field_name", "_aggregate_column"]) def test_cast_name(self): from frappe.core.doctype.doctype.test_doctype import new_doctype From bd3ee0d4c02f90a1022473c23d907486ee57f433 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Fri, 20 Jan 2023 22:03:21 +0530 Subject: [PATCH 127/407] fix: Password Strength Progress Bar --- .../js/frappe/form/controls/password.js | 63 ++++++++++++++----- frappe/public/scss/common/controls.scss | 42 +++++++++---- 2 files changed, 78 insertions(+), 27 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/password.js b/frappe/public/js/frappe/form/controls/password.js index f7e5165472..dbc0fe0b81 100644 --- a/frappe/public/js/frappe/form/controls/password.js +++ b/frappe/public/js/frappe/form/controls/password.js @@ -7,22 +7,29 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co make_input() { var me = this; super.make_input(); - this.$input - .parent() - .append($('')); - this.$wrapper - .find(".control-input-wrapper") - .append($('')); - this.indicator = this.$wrapper.find(".password-strength-indicator"); + this.indicator = $( + `` + ).insertAfter(this.$input); + + this.progress_text = this.indicator.find(".progress-text"); + this.progress_bar = this.indicator.find(".progress-bar"); this.message = this.$wrapper.find(".help-box"); - this.$input.on("keyup", () => { - clearTimeout(this.check_password_timeout); - this.check_password_timeout = setTimeout(() => { + this.$input.on( + "keyup", + frappe.utils.debounce(() => { me.get_password_strength(me.$input.val()); - }, 500); - }); + }, 500) + ); } disable_password_checks() { @@ -33,6 +40,13 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co if (!this.enable_password_checks) { return; } + + if (!value) { + this.indicator.addClass("hidden"); + this.message.addClass("hidden"); + return; + } + var me = this; frappe.call({ type: "POST", @@ -43,15 +57,34 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co callback: function (r) { if (r.message) { let score = r.message.score; - var indicators = ["red", "red", "orange", "yellow", "green"]; + var indicators = ["red", "red", "orange", "blue", "green"]; me.set_strength_indicator(indicators[score]); } }, }); } set_strength_indicator(color) { - var message = __("Include symbols, numbers and capital letters in the password"); - this.indicator.removeClass().addClass("password-strength-indicator indicator " + color); + let strength = { + red: [__("Weak"), "danger", 25], + orange: [__("Average"), "warning", 50], + blue: [__("Strong"), "info", 75], + green: [__("Excellent"), "success", 100], + }; + let progress_text = strength[color][0]; + let progress_percent = strength[color][2]; + let progress_color = strength[color][1]; + + this.indicator.removeClass("hidden"); + + this.progress_text.html(progress_text).css("color", `var(--${color}-500)`); + + this.progress_bar + .css("width", progress_percent + "%") + .attr("aria-valuenow", progress_percent) + .removeClass() + .addClass("progress-bar progress-bar-" + progress_color); + + let message = __("Include symbols, numbers and capital letters in the password"); this.message.html(message).toggleClass("hidden", color == "green"); } }; diff --git a/frappe/public/scss/common/controls.scss b/frappe/public/scss/common/controls.scss index 0f42f6af9d..c663d67eac 100644 --- a/frappe/public/scss/common/controls.scss +++ b/frappe/public/scss/common/controls.scss @@ -5,20 +5,34 @@ @import "phone_picker"; // password -.form-control[data-fieldtype="Password"] { - position: inherit; -} +.frappe-control[data-fieldtype="Password"] { + .control-input-wrapper { + position: relative; -.password-strength-indicator { - // TODO: Review - float: right; - padding: 15px; - margin-top: -41px; - margin-right: -7px; -} + .form-control[data-fieldtype="Password"] { + position: inherit; + } -.password-strength-message { - margin-top: -10px; + .password-strength-indicator { + display: flex; + align-items: center; + position: absolute; + gap: 5px; + top: -20px; + right: 0px; + + .progress-text { + font-size: var(--text-xs); + font-weight: 600; + } + + .progress { + background-color: var(--bg-light-gray); + width: 100px; + height: 5px; + } + } + } } // select @@ -232,6 +246,10 @@ a.progress-small { background-color: var(--red-500); } +.progress-bar-info { + background-color: var(--blue-500); +} + .progress-bar-warning { background-color: var(--orange-500); } From 30941c49f52e723acd93d088de022dea47d6d344 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Sat, 21 Jan 2023 17:28:42 +0530 Subject: [PATCH 128/407] chore!: remove special local cache for documents --- frappe/__init__.py | 29 +++-------------------------- frappe/cache_manager.py | 2 -- 2 files changed, 3 insertions(+), 28 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index 52aa734f8a..4c1540e7a5 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -238,7 +238,6 @@ def init(site: str, sites_path: str = ".", new_site: bool = False) -> None: local.jenv = None local.jloader = None local.cache = {} - local.document_cache = {} local.form_dict = _dict() local.preload_assets = {"style": [], "script": []} local.session = _dict() @@ -1075,25 +1074,10 @@ def set_value(doctype, docname, fieldname, value=None): def get_cached_doc(*args, **kwargs) -> "Document": - def _respond(doc, from_redis=False): - if isinstance(doc, dict): - local.document_cache[key] = doc = get_doc(doc) - - elif from_redis: - local.document_cache[key] = doc - + if (key := can_cache_doc(args)) and (doc := cache().hget("document_cache", key)): return doc - if key := can_cache_doc(args): - # local cache - has "ready" `Document` objects - if doc := local.document_cache.get(key): - return _respond(doc) - - # redis cache - if doc := cache().hget("document_cache", key): - return _respond(doc, True) - - # Not found in local/redis, fetch from DB + # Not found in cache, fetch from DB doc = get_doc(*args, **kwargs) # Store in cache @@ -1106,14 +1090,7 @@ def get_cached_doc(*args, **kwargs) -> "Document": def _set_document_in_cache(key: str, doc: "Document") -> None: - local.document_cache[key] = doc - - # Avoid setting in local.cache since we're already using local.document_cache above - # Try pickling the doc object as-is first, else fallback to doc.as_dict() - try: - cache().hset("document_cache", key, doc, cache_locally=False) - except Exception: - cache().hset("document_cache", key, doc.as_dict(), cache_locally=False) + cache().hset("document_cache", key, doc) def can_cache_doc(args) -> str | None: diff --git a/frappe/cache_manager.py b/frappe/cache_manager.py index 9c9f081c60..24a4c6a271 100644 --- a/frappe/cache_manager.py +++ b/frappe/cache_manager.py @@ -127,8 +127,6 @@ def clear_doctype_cache(doctype=None): for key in ("is_table", "doctype_modules", "document_cache"): cache.delete_value(key) - frappe.local.document_cache = {} - def clear_single(dt): for name in doctype_cache_keys: cache.hdel(name, dt) From 05c03a9345598aabfb7832219fe94fd2a02d3105 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Sat, 21 Jan 2023 17:32:24 +0530 Subject: [PATCH 129/407] chore!: remove `cache_locally` parameter --- frappe/utils/redis_wrapper.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/frappe/utils/redis_wrapper.py b/frappe/utils/redis_wrapper.py index e470c83d75..ea91299cfc 100644 --- a/frappe/utils/redis_wrapper.py +++ b/frappe/utils/redis_wrapper.py @@ -45,7 +45,7 @@ class RedisWrapper(redis.Redis): return f"{frappe.conf.db_name}|{key}".encode() - def set_value(self, key, val, user=None, expires_in_sec=None, shared=False, cache_locally=True): + def set_value(self, key, val, user=None, expires_in_sec=None, shared=False): """Sets cache value. :param key: Cache key @@ -55,7 +55,7 @@ class RedisWrapper(redis.Redis): """ key = self.make_key(key, user, shared) - if not expires_in_sec and cache_locally: + if not expires_in_sec: frappe.local.cache[key] = val try: @@ -169,7 +169,6 @@ class RedisWrapper(redis.Redis): key: str, value, shared: bool = False, - cache_locally: bool = True, *args, **kwargs, ): @@ -179,8 +178,7 @@ class RedisWrapper(redis.Redis): _name = self.make_key(name, shared=shared) # set in local - if cache_locally: - frappe.local.cache.setdefault(_name, {})[key] = value + frappe.local.cache.setdefault(_name, {})[key] = value # set in redis try: From 23c9d8a42d0d62c779997962d82c36e938d460a7 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Sat, 21 Jan 2023 17:46:41 +0530 Subject: [PATCH 130/407] chore: remove old cache reference --- frappe/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index 4c1540e7a5..d533e65b58 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1116,12 +1116,11 @@ def get_document_cache_key(doctype: str, name: str): def clear_document_cache(doctype, name): cache().hdel("last_modified", doctype) - key = get_document_cache_key(doctype, name) - if key in local.document_cache: - del local.document_cache[key] - cache().hdel("document_cache", key) + cache().hdel("document_cache", get_document_cache_key(doctype, name)) + if doctype == "System Settings" and hasattr(local, "system_settings"): delattr(local, "system_settings") + if doctype == "Website Settings" and hasattr(local, "website_settings"): delattr(local, "website_settings") From 64cb507fae19b2fe367a4b19d0987e323a9e8652 Mon Sep 17 00:00:00 2001 From: phot0n Date: Wed, 4 Jan 2023 14:18:00 +0530 Subject: [PATCH 131/407] chore: verbose confimation dialog message --- .../doctype/submission_queue/submission_queue.js | 12 +++++++++--- .../doctype/submission_queue/submission_queue.py | 4 +--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/frappe/core/doctype/submission_queue/submission_queue.js b/frappe/core/doctype/submission_queue/submission_queue.js index fc1e83ac49..6e64be780a 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.js +++ b/frappe/core/doctype/submission_queue/submission_queue.js @@ -5,9 +5,15 @@ frappe.ui.form.on("Submission Queue", { refresh: function (frm) { if (frm.doc.status === "Queued" && frappe.boot.user.roles.includes("System Manager")) { frm.add_custom_button(__("Unlock Reference Document"), () => { - frappe.confirm(__("Are you sure you want to go ahead with this action?"), () => { - frm.call("unlock_doc"); - }); + frappe.confirm( + ` + Are you sure you want to go ahead with this action? + Doing this could unlock other submissions of this document which are in queue (if present) + and could lead to non-ideal conditions.`, + () => { + frm.call("unlock_doc"); + } + ); }); } }, diff --git a/frappe/core/doctype/submission_queue/submission_queue.py b/frappe/core/doctype/submission_queue/submission_queue.py index b1e20516a8..83b3154780 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.py +++ b/frappe/core/doctype/submission_queue/submission_queue.py @@ -4,8 +4,6 @@ from urllib.parse import quote from rq import get_current_job -from rq.exceptions import NoSuchJobError -from rq.job import Job import frappe from frappe import _ @@ -68,7 +66,7 @@ class SubmissionQueue(Document): ) def background_submission(self, to_be_queued_doc: Document, action_for_queuing: str): - # Set the job id for that submission doctyp + # Set the job id for that submission doctype self.update_job_id(get_current_job().id) _action = action_for_queuing.lower() From 08c8ab02291cc8246cda21029aa50bc0629dde01 Mon Sep 17 00:00:00 2001 From: phot0n Date: Wed, 4 Jan 2023 17:00:27 +0530 Subject: [PATCH 132/407] chore: better notification message Co-authored-by: Aradhya-Tripathi --- frappe/core/doctype/doctype/doctype.json | 5 +++-- .../doctype/submission_queue/submission_queue.py | 16 +++++++++------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index 14ef2fd8fb..671a6e86e6 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -604,6 +604,7 @@ { "default": "0", "depends_on": "eval: doc.is_submittable", + "description": "Enabling this will submit documents in background", "fieldname": "queue_in_background", "fieldtype": "Check", "label": "Queue in Background" @@ -707,7 +708,7 @@ "link_fieldname": "reference_doctype" } ], - "modified": "2022-12-14 09:47:27.315351", + "modified": "2023-01-04 17:23:09.206018", "modified_by": "Administrator", "module": "Core", "name": "DocType", @@ -744,4 +745,4 @@ "states": [], "track_changes": 1, "translated_doctype": 1 -} +} \ No newline at end of file diff --git a/frappe/core/doctype/submission_queue/submission_queue.py b/frappe/core/doctype/submission_queue/submission_queue.py index 83b3154780..0400fbef67 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.py +++ b/frappe/core/doctype/submission_queue/submission_queue.py @@ -95,16 +95,16 @@ class SubmissionQueue(Document): if submission_status == "Failed": doctype = self.doctype docname = self.name - message = _("Submission of {0} {1} with action {2} failed") + message = _("Action {0} failed on {1} {2}. View it {3}") else: doctype = self.ref_doctype docname = self.ref_docname - message = _("Submission of {0} {1} with action {2} completed successfully") + message = _("Action {0} completed successfully on {1} {2}. View it {3}") - message = message.format( + message_replacements = ( + frappe.bold(action), frappe.bold(str(self.ref_doctype)), frappe.bold(str(self.ref_docname)), - frappe.bold(action), ) time_diff = time_diff_in_seconds(now(), self.created_at) @@ -112,8 +112,10 @@ class SubmissionQueue(Document): frappe.publish_realtime( "msgprint", { - "message": message - + f". View it
here", + "message": message.format( + *message_replacements, + f"here", + ), "alert": True, "indicator": "red" if submission_status == "Failed" else "green", }, @@ -124,7 +126,7 @@ class SubmissionQueue(Document): "type": "Alert", "document_type": doctype, "document_name": docname, - "subject": message, + "subject": message.format(*message_replacements, "here"), } notify_to = frappe.db.get_value("User", self.enqueued_by, fieldname="email") From bb8b0d415ec9881f8dfd4afa62ebc51f159ba538 Mon Sep 17 00:00:00 2001 From: phot0n Date: Thu, 5 Jan 2023 16:22:52 +0530 Subject: [PATCH 133/407] fix: workflow mechanics for submission queue (start) --- .../submission_queue/submission_queue.py | 15 ++++++---- frappe/model/workflow.py | 15 ++++++++-- .../workflow_action/workflow_action.py | 30 ++++++++++++++----- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/frappe/core/doctype/submission_queue/submission_queue.py b/frappe/core/doctype/submission_queue/submission_queue.py index 0400fbef67..d64ecd7d93 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.py +++ b/frappe/core/doctype/submission_queue/submission_queue.py @@ -1,6 +1,7 @@ # Copyright (c) 2022, Frappe Technologies and contributors # For license information, please see license.txt +from typing import Callable from urllib.parse import quote from rq import get_current_job @@ -35,10 +36,11 @@ class SubmissionQueue(Document): table = frappe.qb.DocType("Submission Queue") frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days)))) - def insert(self, to_be_queued_doc: Document, action: str): + def insert(self, to_be_queued_doc: Document, action: str, **job_kwargs): self.status = "Queued" self.to_be_queued_doc = to_be_queued_doc self.action_for_queuing = action + self.kwargs = job_kwargs super().insert(ignore_permissions=True) def lock(self): @@ -57,12 +59,15 @@ class SubmissionQueue(Document): frappe.db.commit() def after_insert(self): + self.kwargs.pop("enqueue_after_commit", None) + self.queue_action( "background_submission", to_be_queued_doc=self.queued_doc, action_for_queuing=self.action_for_queuing, - timeout=600, + timeout=self.kwargs.pop("timeout", 600), enqueue_after_commit=True, + **self.kwargs, ) def background_submission(self, to_be_queued_doc: Document, action_for_queuing: str): @@ -99,7 +104,7 @@ class SubmissionQueue(Document): else: doctype = self.ref_doctype docname = self.ref_docname - message = _("Action {0} completed successfully on {1} {2}. View it {3}") + message = _("Action {0} completed on {1} {2}. View it {3}") message_replacements = ( frappe.bold(action), @@ -145,11 +150,11 @@ class SubmissionQueue(Document): frappe.msgprint(_("Document Unlocked")) -def queue_submission(doc: Document, action: str, alert: bool = True): +def queue_submission(doc: Document, action: str, alert: bool = True, **job_kwargs): queue = frappe.new_doc("Submission Queue") queue.ref_doctype = doc.doctype queue.ref_docname = doc.name - queue.insert(doc, action) + queue.insert(doc, action, **job_kwargs) if alert: frappe.msgprint( diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index e7835fba8d..a2155e43f2 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -136,7 +136,15 @@ def apply_workflow(doc, action): doc.save() elif doc.docstatus.is_draft() and new_docstatus == DocStatus.submitted(): if doc.meta.queue_in_background and not is_scheduler_inactive(): - queue_submission(doc, action="submit") + queue_submission( + doc, + action="submit", + on_success=lambda job, connection, result, *args, **kwargs: ( + doc.add_comment("Workflow", _(next_state.state)), + frappe.db.commit(), + ), + ) + return else: doc.submit() elif doc.docstatus.is_submitted() and new_docstatus == DocStatus.submitted(): @@ -257,8 +265,11 @@ def bulk_workflow_approval(docnames, doctype, action): message_dict = {} try: show_progress(docnames, _("Applying: {0}").format(action), idx, docname) - apply_workflow(frappe.get_doc(doctype, docname), action) + d = apply_workflow(frappe.get_doc(doctype, docname), action) frappe.db.commit() + if not d: + # for background submission + continue except Exception as e: if not frappe.message_log: # Exception is raised manually and not from msgprint or throw diff --git a/frappe/workflow/doctype/workflow_action/workflow_action.py b/frappe/workflow/doctype/workflow_action/workflow_action.py index 545ad6ec77..cc3a6c25dd 100644 --- a/frappe/workflow/doctype/workflow_action/workflow_action.py +++ b/frappe/workflow/doctype/workflow_action/workflow_action.py @@ -140,20 +140,36 @@ def confirm_action(doctype, docname, user, action): doc = frappe.get_doc(doctype, docname) newdoc = apply_workflow(doc, action) frappe.db.commit() - return_success_page(newdoc) + + return_response_page(doc, not newdoc) # reset session user if logged_in_user == "Guest": frappe.set_user(logged_in_user) -def return_success_page(doc): +def return_response_page(doc, is_background=False): + if is_background: + title = _("Pending") + message = ( + _("{0}: {1} is added in queue to set to next state").format( + doc.get("doctype"), frappe.bold(doc.get("name")) + ), + ) + color = "orange" + else: + title = _("Success") + message = ( + _("{0}: {1} is set to state {2}").format( + doc.get("doctype"), frappe.bold(doc.get("name")), frappe.bold(get_doc_workflow_state(doc)) + ), + ) + color = "green" + frappe.respond_as_web_page( - _("Success"), - _("{0}: {1} is set to state {2}").format( - doc.get("doctype"), frappe.bold(doc.get("name")), frappe.bold(get_doc_workflow_state(doc)) - ), - indicator_color="green", + title, + message, + indicator_color=color, ) From af093dd5987adec83f02b5ff6579fd3a552952be Mon Sep 17 00:00:00 2001 From: phot0n Date: Mon, 23 Jan 2023 12:46:21 +0530 Subject: [PATCH 134/407] fix: traceback with context for submission queue --- .../doctype/submission_queue/submission_queue.json | 12 +++--------- .../doctype/submission_queue/submission_queue.py | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/frappe/core/doctype/submission_queue/submission_queue.json b/frappe/core/doctype/submission_queue/submission_queue.json index 4058276319..590346a53c 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.json +++ b/frappe/core/doctype/submission_queue/submission_queue.json @@ -20,9 +20,8 @@ "fields": [ { "fieldname": "job_id", - "fieldtype": "Link", + "fieldtype": "Data", "label": "Job Id", - "options": "RQ Job", "read_only": 1 }, { @@ -81,14 +80,14 @@ }, { "fieldname": "exception", - "fieldtype": "Text", + "fieldtype": "Long Text", "label": "Exception", "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2023-01-03 20:54:40.904584", + "modified": "2023-01-23 12:45:53.997708", "modified_by": "Administrator", "module": "Core", "name": "Submission Queue", @@ -103,11 +102,6 @@ "report": 1, "role": "System Manager", "share": 1 - }, - { - "if_owner": 1, - "read": 1, - "role": "All" } ], "sort_field": "modified", diff --git a/frappe/core/doctype/submission_queue/submission_queue.py b/frappe/core/doctype/submission_queue/submission_queue.py index d64ecd7d93..07e3a64055 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.py +++ b/frappe/core/doctype/submission_queue/submission_queue.py @@ -89,7 +89,7 @@ class SubmissionQueue(Document): ) values = {"status": "Finished"} except Exception: - values = {"status": "Failed", "exception": frappe.get_traceback()} + values = {"status": "Failed", "exception": frappe.get_traceback(with_context=True)} frappe.db.rollback() values["ended_at"] = now() From 99fbe969e89bfd14486fbc82876b33e247492ef2 Mon Sep 17 00:00:00 2001 From: phot0n Date: Mon, 23 Jan 2023 12:48:04 +0530 Subject: [PATCH 135/407] Revert "fix: workflow mechanics for submission queue (start)" This reverts commit bb8b0d415ec9881f8dfd4afa62ebc51f159ba538. --- .../submission_queue/submission_queue.py | 15 ++++------ frappe/model/workflow.py | 15 ++-------- .../workflow_action/workflow_action.py | 30 +++++-------------- 3 files changed, 14 insertions(+), 46 deletions(-) diff --git a/frappe/core/doctype/submission_queue/submission_queue.py b/frappe/core/doctype/submission_queue/submission_queue.py index 07e3a64055..be0c20fc32 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.py +++ b/frappe/core/doctype/submission_queue/submission_queue.py @@ -1,7 +1,6 @@ # Copyright (c) 2022, Frappe Technologies and contributors # For license information, please see license.txt -from typing import Callable from urllib.parse import quote from rq import get_current_job @@ -36,11 +35,10 @@ class SubmissionQueue(Document): table = frappe.qb.DocType("Submission Queue") frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days)))) - def insert(self, to_be_queued_doc: Document, action: str, **job_kwargs): + def insert(self, to_be_queued_doc: Document, action: str): self.status = "Queued" self.to_be_queued_doc = to_be_queued_doc self.action_for_queuing = action - self.kwargs = job_kwargs super().insert(ignore_permissions=True) def lock(self): @@ -59,15 +57,12 @@ class SubmissionQueue(Document): frappe.db.commit() def after_insert(self): - self.kwargs.pop("enqueue_after_commit", None) - self.queue_action( "background_submission", to_be_queued_doc=self.queued_doc, action_for_queuing=self.action_for_queuing, - timeout=self.kwargs.pop("timeout", 600), + timeout=600, enqueue_after_commit=True, - **self.kwargs, ) def background_submission(self, to_be_queued_doc: Document, action_for_queuing: str): @@ -104,7 +99,7 @@ class SubmissionQueue(Document): else: doctype = self.ref_doctype docname = self.ref_docname - message = _("Action {0} completed on {1} {2}. View it {3}") + message = _("Action {0} completed successfully on {1} {2}. View it {3}") message_replacements = ( frappe.bold(action), @@ -150,11 +145,11 @@ class SubmissionQueue(Document): frappe.msgprint(_("Document Unlocked")) -def queue_submission(doc: Document, action: str, alert: bool = True, **job_kwargs): +def queue_submission(doc: Document, action: str, alert: bool = True): queue = frappe.new_doc("Submission Queue") queue.ref_doctype = doc.doctype queue.ref_docname = doc.name - queue.insert(doc, action, **job_kwargs) + queue.insert(doc, action) if alert: frappe.msgprint( diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index a2155e43f2..e7835fba8d 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -136,15 +136,7 @@ def apply_workflow(doc, action): doc.save() elif doc.docstatus.is_draft() and new_docstatus == DocStatus.submitted(): if doc.meta.queue_in_background and not is_scheduler_inactive(): - queue_submission( - doc, - action="submit", - on_success=lambda job, connection, result, *args, **kwargs: ( - doc.add_comment("Workflow", _(next_state.state)), - frappe.db.commit(), - ), - ) - return + queue_submission(doc, action="submit") else: doc.submit() elif doc.docstatus.is_submitted() and new_docstatus == DocStatus.submitted(): @@ -265,11 +257,8 @@ def bulk_workflow_approval(docnames, doctype, action): message_dict = {} try: show_progress(docnames, _("Applying: {0}").format(action), idx, docname) - d = apply_workflow(frappe.get_doc(doctype, docname), action) + apply_workflow(frappe.get_doc(doctype, docname), action) frappe.db.commit() - if not d: - # for background submission - continue except Exception as e: if not frappe.message_log: # Exception is raised manually and not from msgprint or throw diff --git a/frappe/workflow/doctype/workflow_action/workflow_action.py b/frappe/workflow/doctype/workflow_action/workflow_action.py index cc3a6c25dd..545ad6ec77 100644 --- a/frappe/workflow/doctype/workflow_action/workflow_action.py +++ b/frappe/workflow/doctype/workflow_action/workflow_action.py @@ -140,36 +140,20 @@ def confirm_action(doctype, docname, user, action): doc = frappe.get_doc(doctype, docname) newdoc = apply_workflow(doc, action) frappe.db.commit() - - return_response_page(doc, not newdoc) + return_success_page(newdoc) # reset session user if logged_in_user == "Guest": frappe.set_user(logged_in_user) -def return_response_page(doc, is_background=False): - if is_background: - title = _("Pending") - message = ( - _("{0}: {1} is added in queue to set to next state").format( - doc.get("doctype"), frappe.bold(doc.get("name")) - ), - ) - color = "orange" - else: - title = _("Success") - message = ( - _("{0}: {1} is set to state {2}").format( - doc.get("doctype"), frappe.bold(doc.get("name")), frappe.bold(get_doc_workflow_state(doc)) - ), - ) - color = "green" - +def return_success_page(doc): frappe.respond_as_web_page( - title, - message, - indicator_color=color, + _("Success"), + _("{0}: {1} is set to state {2}").format( + doc.get("doctype"), frappe.bold(doc.get("name")), frappe.bold(get_doc_workflow_state(doc)) + ), + indicator_color="green", ) From 230d36e7899e1a1307a59abe5e8ee732102ec88e Mon Sep 17 00:00:00 2001 From: phot0n Date: Mon, 23 Jan 2023 12:49:36 +0530 Subject: [PATCH 136/407] chore: revert things related to workflow --- frappe/model/workflow.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/frappe/model/workflow.py b/frappe/model/workflow.py index e7835fba8d..8338157996 100644 --- a/frappe/model/workflow.py +++ b/frappe/model/workflow.py @@ -101,9 +101,6 @@ def is_transition_condition_satisfied(transition, doc) -> bool: @frappe.whitelist() def apply_workflow(doc, action): """Allow workflow action on the current doc""" - from frappe.core.doctype.submission_queue.submission_queue import queue_submission - from frappe.utils.scheduler import is_scheduler_inactive - doc = frappe.get_doc(frappe.parse_json(doc)) workflow = get_workflow(doc.doctype) transitions = get_transitions(doc, workflow) @@ -135,10 +132,7 @@ def apply_workflow(doc, action): if doc.docstatus.is_draft() and new_docstatus == DocStatus.draft(): doc.save() elif doc.docstatus.is_draft() and new_docstatus == DocStatus.submitted(): - if doc.meta.queue_in_background and not is_scheduler_inactive(): - queue_submission(doc, action="submit") - else: - doc.submit() + doc.submit() elif doc.docstatus.is_submitted() and new_docstatus == DocStatus.submitted(): doc.save() elif doc.docstatus.is_submitted() and new_docstatus == DocStatus.cancelled(): From 4144c45b2a12b7b50be214d378c01e3f271cdfba Mon Sep 17 00:00:00 2001 From: phot0n Date: Mon, 23 Jan 2023 13:13:27 +0530 Subject: [PATCH 137/407] fix: make job_id link field and allow all (if owner) to read their submission queues --- .../core/doctype/submission_queue/submission_queue.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/submission_queue/submission_queue.json b/frappe/core/doctype/submission_queue/submission_queue.json index 590346a53c..04668e1c76 100644 --- a/frappe/core/doctype/submission_queue/submission_queue.json +++ b/frappe/core/doctype/submission_queue/submission_queue.json @@ -20,8 +20,9 @@ "fields": [ { "fieldname": "job_id", - "fieldtype": "Data", + "fieldtype": "Link", "label": "Job Id", + "options": "RQ Job", "read_only": 1 }, { @@ -102,6 +103,11 @@ "report": 1, "role": "System Manager", "share": 1 + }, + { + "if_owner": 1, + "read": 1, + "role": "All" } ], "sort_field": "modified", From 0e6e2609b5cae8a23b5553abcb5c7bd2d3b3677a Mon Sep 17 00:00:00 2001 From: Richard Case <110036763+casesolved-co-uk@users.noreply.github.com> Date: Mon, 23 Jan 2023 09:17:36 +0000 Subject: [PATCH 138/407] fix: unhelpful error message (#19666) --- frappe/utils/password.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/utils/password.py b/frappe/utils/password.py index c033f4682b..fa2e03bde5 100644 --- a/frappe/utils/password.py +++ b/frappe/utils/password.py @@ -62,7 +62,10 @@ def get_decrypted_password(doctype, name, fieldname="password", raise_exception= return decrypt(result[0][0]) elif raise_exception: - frappe.throw(_("Password not found"), frappe.AuthenticationError) + frappe.throw( + _("Password not found for {0} {1} {2}").format(doctype, name, fieldname), + frappe.AuthenticationError, + ) def set_encrypted_password(doctype, name, pwd, fieldname="password"): From 6554919f1e2b2108ddeae55a2c698f10c8ab253d Mon Sep 17 00:00:00 2001 From: Anand Baburajan Date: Mon, 23 Jan 2023 15:00:04 +0530 Subject: [PATCH 139/407] fix: improve invalid naming series message (#19711) * fix: show the invalid naming series in special chars error msg * chore: translations [skip ci] --- frappe/model/naming.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/model/naming.py b/frappe/model/naming.py index 93be2204b4..29831451b0 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -59,8 +59,8 @@ class NamingSeries: if not NAMING_SERIES_PATTERN.match(self.series): frappe.throw( _( - 'Special Characters except "-", "#", ".", "/", "{" and "}" not allowed in naming series', - ), + "Special Characters except '-', '#', '.', '/', '{{' and '}}' not allowed in naming series {0}" + ).format(frappe.bold(self.series)), exc=InvalidNamingSeriesError, ) From 87561940a4cc70032cfc22ee8b5e5d21287e42c9 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 23 Jan 2023 15:04:43 +0530 Subject: [PATCH 140/407] feat: Interactively add a new patch (#19722) --- frappe/commands/utils.py | 11 +++ frappe/tests/test_boilerplate.py | 34 +++++++++- frappe/utils/boilerplate.py | 111 +++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 2 deletions(-) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index bb943c7223..f4f2cd8744 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -1030,6 +1030,16 @@ def make_app(destination, app_name, no_git=False): make_boilerplate(destination, app_name, no_git=no_git) +@click.command("create-patch") +def create_patch(): + "Creates a new patch interactively" + from frappe.utils.boilerplate import PatchCreator + + pc = PatchCreator() + pc.fetch_user_inputs() + pc.create_patch_file() + + @click.command("set-config") @click.argument("key") @click.argument("value") @@ -1176,6 +1186,7 @@ commands = [ data_import, import_doc, make_app, + create_patch, mariadb, postgres, request, diff --git a/frappe/tests/test_boilerplate.py b/frappe/tests/test_boilerplate.py index 8dd29b24af..999c74592e 100644 --- a/frappe/tests/test_boilerplate.py +++ b/frappe/tests/test_boilerplate.py @@ -2,22 +2,25 @@ import ast import copy import glob import os +import pathlib import shutil +import unittest from io import StringIO from unittest.mock import patch import yaml import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.modules.patch_handler import get_all_patches from frappe.utils.boilerplate import ( + PatchCreator, _create_app_boilerplate, _get_user_inputs, github_workflow_template, ) -class TestBoilerPlate(FrappeTestCase): +class TestBoilerPlate(unittest.TestCase): @classmethod def setUpClass(cls): super().setUpClass() @@ -180,3 +183,30 @@ class TestBoilerPlate(FrappeTestCase): ast.parse(p.read()) except Exception as e: self.fail(f"Can't parse python file in new app: {python_file}\n" + str(e)) + + def test_new_patch_util(self): + user_inputs = { + "app_name": "frappe", + "doctype": "User", + "docstring": "Delete all users", + "file_name": "", # Accept default + "patch_folder_confirmation": "Y", + } + + patches_txt = pathlib.Path(pathlib.Path(frappe.get_app_path("frappe", "patches.txt"))) + original_patches = patches_txt.read_text() + + with patch("sys.stdin", self.get_user_input_stream(user_inputs)): + patch_creator = PatchCreator() + patch_creator.fetch_user_inputs() + patch_creator.create_patch_file() + + patches = get_all_patches() + expected_patch = "frappe.core.doctype.user.patches.delete_all_users" + self.assertIn(expected_patch, patches) + + self.assertTrue(patch_creator.patch_file.exists()) + + # Cleanup + shutil.rmtree(patch_creator.patch_file.parents[0]) + patches_txt.write_text(original_patches) diff --git a/frappe/utils/boilerplate.py b/frappe/utils/boilerplate.py index 0963d9fabe..ee75c672a8 100644 --- a/frappe/utils/boilerplate.py +++ b/frappe/utils/boilerplate.py @@ -1,9 +1,13 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE +import contextlib +import glob +import json import os import pathlib import re +import textwrap import click import git @@ -162,6 +166,113 @@ def _create_github_workflow_files(dest, hooks): f.write(github_workflow_template.format(**hooks)) +PATCH_TEMPLATE = textwrap.dedent( + ''' + import frappe + + def execute(): + """{docstring}""" + + # Write your patch here. + pass +''' +) + + +class PatchCreator: + def __init__(self): + self.all_apps = frappe.get_all_apps(sites_path=".", with_internal_apps=False) + + self.app = None + self.app_dir = None + self.patch_dir = None + self.filename = None + self.docstring = None + self.patch_file = None + + def fetch_user_inputs(self): + self._ask_app_name() + self._ask_doctype_name() + self._ask_patch_meta_info() + + def _ask_app_name(self): + self.app = click.prompt("Select app for new patch", type=click.Choice(self.all_apps)) + self.app_dir = pathlib.Path(frappe.get_app_path(self.app)) + + def _ask_doctype_name(self): + def _doctype_name(filename): + with contextlib.suppress(Exception): + with open(filename) as f: + return json.load(f).get("name") + + doctype_files = list(glob.glob(f"{self.app_dir}/**/doctype/**/*.json")) + doctype_map = {_doctype_name(file): file for file in doctype_files} + doctype_map.pop(None, None) + + doctype = click.prompt( + "Provide DocType name on which this patch will apply", + type=click.Choice(doctype_map.keys()), + show_choices=False, + ) + self.patch_dir = pathlib.Path(doctype_map[doctype]).parents[0] / "patches" + + def _ask_patch_meta_info(self): + self.docstring = click.prompt("Describe what this patch does", type=str) + default_filename = frappe.scrub(self.docstring) + ".py" + + def _valid_filename(name): + if not name: + return + + match name.partition("."): + case filename, ".", "py" if filename.isidentifier(): + return True + case _: + click.echo(f"{name} is not a valid python file name") + + while not _valid_filename(self.filename): + self.filename = click.prompt( + "Provide filename for this patch", type=str, default=default_filename + ) + + def create_patch_file(self): + self._create_parent_folder_if_not_exists() + + self.patch_file = self.patch_dir / self.filename + + if self.patch_file.exists(): + raise Exception(f"Patch {self.patch_file} already exists") + + *path, _filename = self.patch_file.relative_to(self.app_dir.parents[0]).parts + dotted_path = ".".join(path + [self.patch_file.stem]) + + patches_txt = self.app_dir / "patches.txt" + existing_patches = patches_txt.read_text() + + if dotted_path in existing_patches: + raise Exception(f"Patch {dotted_path} is already present in patches.txt") + + self.patch_file.write_text(PATCH_TEMPLATE.format(docstring=self.docstring)) + + with open(patches_txt, "a+") as f: + if not existing_patches.endswith("\n"): + f.write("\n") # ensure EOF + f.write(dotted_path + "\n") + click.echo(f"Created {self.patch_file} and updated patches.txt") + + def _create_parent_folder_if_not_exists(self): + if not self.patch_dir.exists(): + click.confirm( + f"Patch folder '{self.patch_dir}' doesn't exist, create it?", + abort=True, + default=True, + ) + self.patch_dir.mkdir() + + init_py = self.patch_dir / "__init__.py" + init_py.touch() + + manifest_template = """include MANIFEST.in include requirements.txt include *.json From 224ab37924030b3f13d383c63a6c34d017865439 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 20 Jan 2023 23:58:57 +0530 Subject: [PATCH 141/407] fix: Dont apply non-standard perms in migrate ref: agent-job/b8bca95f25 --- frappe/core/doctype/user_type/user_type.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/core/doctype/user_type/user_type.py b/frappe/core/doctype/user_type/user_type.py index 5917ba2756..39d9133412 100644 --- a/frappe/core/doctype/user_type/user_type.py +++ b/frappe/core/doctype/user_type/user_type.py @@ -287,7 +287,7 @@ def user_linked_with_permission_on_doctype(doc, user): def apply_permissions_for_non_standard_user_type(doc, method=None): """Create user permission for the non standard user type""" - if not frappe.db.table_exists("User Type"): + if not frappe.db.table_exists("User Type") or frappe.flags.in_migrate: return user_types = frappe.cache().get_value( From 6c1624d41e1bec74b279b172ff783f847ec03848 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 23 Jan 2023 16:23:44 +0530 Subject: [PATCH 142/407] feat: toggle password icon --- .../js/frappe/form/controls/password.js | 22 ++++++++++++++++++- frappe/public/scss/common/controls.scss | 9 ++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/controls/password.js b/frappe/public/js/frappe/form/controls/password.js index dbc0fe0b81..2e575e3e19 100644 --- a/frappe/public/js/frappe/form/controls/password.js +++ b/frappe/public/js/frappe/form/controls/password.js @@ -27,9 +27,29 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co this.$input.on( "keyup", frappe.utils.debounce(() => { + let hide_icon = me.$input.val() && !me.$input.val().includes("*"); + me.toggle_password.toggleClass("hidden", !hide_icon); me.get_password_strength(me.$input.val()); }, 500) ); + + this.toggle_password = $(` + + `).insertAfter(this.$input); + + this.toggle_password.on("click", () => { + if (this.$input.attr("type") === "password") { + this.$input.attr("type", "text"); + this.toggle_password.html(frappe.utils.icon("hide", "sm")); + } else { + this.$input.attr("type", "password"); + this.toggle_password.html(frappe.utils.icon("unhide", "sm")); + } + }); + + !this.value && this.toggle_password.removeClass("hidden"); } disable_password_checks() { @@ -71,8 +91,8 @@ frappe.ui.form.ControlPassword = class ControlPassword extends frappe.ui.form.Co green: [__("Excellent"), "success", 100], }; let progress_text = strength[color][0]; - let progress_percent = strength[color][2]; let progress_color = strength[color][1]; + let progress_percent = strength[color][2]; this.indicator.removeClass("hidden"); diff --git a/frappe/public/scss/common/controls.scss b/frappe/public/scss/common/controls.scss index c663d67eac..08debbbde9 100644 --- a/frappe/public/scss/common/controls.scss +++ b/frappe/public/scss/common/controls.scss @@ -32,6 +32,15 @@ height: 5px; } } + + .toggle-password { + position: absolute; + top: 4px; + right: 8px; + padding: 3px; + z-index: 3; + cursor: pointer; + } } } From 391edba10a58f9046c2b4eeb39e380b38af490cd Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 23 Jan 2023 15:27:57 +0530 Subject: [PATCH 143/407] fix(UX): better error message for dead link fields Link fields referring to non-existing doctypes are possible when - Removing customizations. - Removing app which added a custom field but didn't clean up after itself. [skip ci] --- frappe/desk/form/meta.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index b5b58ebfa3..d6ff71b367 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -4,6 +4,7 @@ import io import os import frappe +from frappe import _ from frappe.build import scrub_html_template from frappe.model.meta import Meta from frappe.model.utils import render_include @@ -182,21 +183,38 @@ class FormMeta(Meta): def add_search_fields(self): """add search fields found in the doctypes indicated by link fields' options""" + # TODO: IF field is not found replace with useful message for df in self.get("fields", {"fieldtype": "Link", "options": ["!=", "[Select]"]}): if df.options: - search_fields = frappe.get_meta(df.options).search_fields + try: + search_fields = frappe.get_meta(df.options).search_fields + except frappe.DoesNotExistError: + self._show_missing_doctype_msg(df) + if search_fields: search_fields = search_fields.split(",") df.search_fields = [sf.strip() for sf in search_fields] + def _show_missing_doctype_msg(self, df): + # A link field is referring to non-existing doctype, this usually happens when + # customizations are removed or some custom app is removed but hasn't cleaned + # up after itself. + frappe.clear_last_message() + customize_form_link = f'Customize Form' + frappe.throw( + _( + "Field {0} is referring to non-existing doctype {1}, please remove the field from {2} or add the required doctype." + ).format(frappe.bold(df.fieldname), frappe.bold(df.options), customize_form_link), + title=_("Missing DocType"), + ) + def add_linked_document_type(self): for df in self.get("fields", {"fieldtype": "Link"}): if df.options: try: df.linked_document_type = frappe.get_meta(df.options).document_type except frappe.DoesNotExistError: - # edge case where options="[Select]" - pass + self._show_missing_doctype_msg(df) def load_print_formats(self): print_formats = frappe.db.sql( From 3d280a2d3f35b2845719d4f7c8ab9be0d4a27f46 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 23 Jan 2023 18:17:21 +0530 Subject: [PATCH 144/407] fix: child table readonly field in dialog is not readonly --- frappe/public/js/frappe/model/perm.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/model/perm.js b/frappe/public/js/frappe/model/perm.js index fdd915ebfc..3884bbfe40 100644 --- a/frappe/public/js/frappe/model/perm.js +++ b/frappe/public/js/frappe/model/perm.js @@ -195,7 +195,9 @@ $.extend(frappe.perm, { } if (!perm) { - return df && (cint(df.hidden) || cint(df.hidden_due_to_dependency)) ? "None" : "Write"; + let is_hidden = df && (cint(df.hidden) || cint(df.hidden_due_to_dependency)); + let is_read_only = df && cint(df.read_only); + return is_hidden ? "None" : is_read_only ? "Read" : "Write"; } if (!df.permlevel) df.permlevel = 0; From 30e9fc4cb6dc823ef4131588aab8d2aaf02ee5fd Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 23 Jan 2023 20:05:28 +0530 Subject: [PATCH 145/407] chore: remove unnessary todo comment --- frappe/desk/form/meta.py | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index d6ff71b367..8b1408f1b5 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -183,7 +183,6 @@ class FormMeta(Meta): def add_search_fields(self): """add search fields found in the doctypes indicated by link fields' options""" - # TODO: IF field is not found replace with useful message for df in self.get("fields", {"fieldtype": "Link", "options": ["!=", "[Select]"]}): if df.options: try: From ce4450cd95c24b2dab9d4189297a0ec95b81a0dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Riandrys=20G=C3=B3ngora=20Rom=C3=A1n?= Date: Mon, 23 Jan 2023 09:47:28 -0500 Subject: [PATCH 146/407] fix(child table): Update docfield property using set_df_property update docfield property using set_df_property set_df_property doesn't work when it's call it in form_render event of child table --- frappe/public/js/frappe/form/form.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 8893c4b69e..9aa7529761 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -1516,7 +1516,7 @@ frappe.ui.form.Form = class FrappeForm { if (this.fields_dict[fieldname].grid.grid_rows_by_docname[table_row_name]) { this.fields_dict[fieldname].grid.grid_rows_by_docname[ table_row_name - ].refresh_field(fieldname); + ].refresh_field(table_field); } } else { this.refresh_field(fieldname); From ba438fe4a6ee3e1232ba214e285a544bf5a71b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Riandrys=20G=C3=B3ngora=20Rom=C3=A1n?= Date: Mon, 23 Jan 2023 11:08:43 -0500 Subject: [PATCH 147/407] test: update docfield property using set_df_property in child table add UI test with cypress on form.js update docfield property using set_df_property in child table --- cypress/integration/form.js | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/cypress/integration/form.js b/cypress/integration/form.js index fa0d758223..8186647a14 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -26,6 +26,11 @@ context("Form", () => { }); }); + beforeEach(() => { + cy.login(); + cy.visit("/app/website"); + }); + it("create a new form", () => { cy.visit("/app/todo/new"); cy.get_field("description", "Text Editor") @@ -172,4 +177,57 @@ context("Form", () => { send_welcome_email: 0, }); }); + + it("update docfield property using set_df_property in child table", () => { + cy.visit("/app/contact/Test Form Contact 1"); + cy.window() + .its("cur_frm") + .then((frm) => { + cy.get('.frappe-control[data-fieldname="phone_nos"]').as("table"); + + // set property before form_render event of child table + cy.get("@table") + .find('[data-idx="1"]') + .invoke("attr", "data-name") + .then((cdn) => { + frm.set_df_property( + "phone_nos", + "hidden", + 1, + "Contact Phone", + "is_primary_phone", + cdn + ); + }); + + cy.get("@table").find('[data-idx="1"] .edit-grid-row').click(); + cy.get(".grid-row-open").as("table-form"); + cy.get("@table-form") + .find('.frappe-control[data-fieldname="is_primary_phone"]') + .should("be.hidden"); + cy.get("@table-form").find(".grid-footer-toolbar").click(); + + // set property on form_render event of child table + cy.get("@table").find('[data-idx="1"] .edit-grid-row').click(); + cy.get("@table") + .find('[data-idx="1"]') + .invoke("attr", "data-name") + .then((cdn) => { + frm.set_df_property( + "phone_nos", + "hidden", + 0, + "Contact Phone", + "is_primary_phone", + cdn + ); + }); + + cy.get(".grid-row-open").as("table-form"); + cy.get("@table-form") + .find('.frappe-control[data-fieldname="is_primary_phone"]') + .should("be.visible"); + cy.get("@table-form").find(".grid-footer-toolbar").click(); + }); + }); }); From 71420eb4e63c3c5e3b6c10e639f56f458e687249 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Mon, 23 Jan 2023 19:57:26 +0000 Subject: [PATCH 148/407] refactor: simplified `get_controller` (#19684) * refactor: simplified `get_controller` * chore: more refactor, better error if not subclass * chore: more correct condition * refactor: `class_` > `_class` * chore: use `Meta` instead of DB calls * chore: `_get_controller` => `import_controller` * style: remove else block --- frappe/model/base_document.py | 80 +++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 1d6d87a7bd..8cd074a4c0 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -35,54 +35,60 @@ DOCTYPES_FOR_DOCTYPE = {"DocType", *TABLE_DOCTYPES_FOR_DOCTYPE.values()} def get_controller(doctype): - """Returns the **class** object of the given DocType. + """ + Returns the locally cached **class** object of the given DocType. For `custom` type, returns `frappe.model.document.Document`. - :param doctype: DocType name as string.""" - - def _get_controller(): - from frappe.model.document import Document - from frappe.utils.nestedset import NestedSet - - module_name, custom = frappe.db.get_value( - "DocType", doctype, ("module", "custom"), cache=True - ) or ("Core", False) - - if custom: - is_tree = frappe.db.get_value("DocType", doctype, "is_tree", ignore=True, cache=True) - _class = NestedSet if is_tree else Document - else: - class_overrides = frappe.get_hooks("override_doctype_class") - if class_overrides and class_overrides.get(doctype): - import_path = class_overrides[doctype][-1] - module_path, classname = import_path.rsplit(".", 1) - module = frappe.get_module(module_path) - if not hasattr(module, classname): - raise ImportError(f"{doctype}: {classname} does not exist in module {module_path}") - else: - module = load_doctype_module(doctype, module_name) - classname = doctype.replace(" ", "").replace("-", "") - - if hasattr(module, classname): - _class = getattr(module, classname) - if issubclass(_class, BaseDocument): - _class = getattr(module, classname) - else: - raise ImportError(doctype) - else: - raise ImportError(doctype) - return _class + :param doctype: DocType name as string. + """ if frappe.local.dev_server: - return _get_controller() + return import_controller(doctype) site_controllers = frappe.controllers.setdefault(frappe.local.site, {}) if doctype not in site_controllers: - site_controllers[doctype] = _get_controller() + site_controllers[doctype] = import_controller(doctype) return site_controllers[doctype] +def import_controller(doctype): + from frappe.model.document import Document + from frappe.utils.nestedset import NestedSet + + module_name = "Core" + if doctype not in DOCTYPES_FOR_DOCTYPE: + meta = frappe.get_meta(doctype) + if meta.custom: + return NestedSet if meta.get("is_tree") else Document + + module_name = meta.module + + module_path = None + class_overrides = frappe.get_hooks("override_doctype_class") + if class_overrides and class_overrides.get(doctype): + import_path = class_overrides[doctype][-1] + module_path, classname = import_path.rsplit(".", 1) + module = frappe.get_module(module_path) + + else: + module = load_doctype_module(doctype, module_name) + classname = doctype.replace(" ", "").replace("-", "") + + class_ = getattr(module, classname, None) + if class_ is None: + raise ImportError( + doctype + if module_path is None + else f"{doctype}: {classname} does not exist in module {module_path}" + ) + + if not issubclass(class_, BaseDocument): + raise ImportError(f"{doctype}: {classname} is not a subclass of BaseDocument") + + return class_ + + class BaseDocument: _reserved_keywords = { "doctype", From 229ca404ae1e4345b9724b4f6c6cf4afa62dffcd Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Tue, 24 Jan 2023 11:22:05 +0530 Subject: [PATCH 149/407] fix: LDAP - default user role mandatory logic broken --- frappe/integrations/doctype/ldap_settings/ldap_settings.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.json b/frappe/integrations/doctype/ldap_settings/ldap_settings.json index b8f73cebed..0b3bf06239 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.json +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.json @@ -88,8 +88,7 @@ "fieldtype": "Link", "label": "Default User Role", "mandatory_depends_on": "eval: doc.default_user_type == \"System User\"", - "options": "Role", - "reqd": 1 + "options": "Role" }, { "description": "Must be enclosed in '()' and include '{0}', which is a placeholder for the user/login name. i.e. (&(objectclass=user)(uid={0}))", @@ -302,7 +301,7 @@ "in_create": 1, "issingle": 1, "links": [], - "modified": "2022-12-05 21:52:31.146035", + "modified": "2023-01-24 11:20:06.049708", "modified_by": "Administrator", "module": "Integrations", "name": "LDAP Settings", From 81d6b282a387800d92466417851fce05b1102d94 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 24 Jan 2023 11:26:31 +0530 Subject: [PATCH 150/407] chore: Remove errprint triggered by passing Query object to db.sql --- frappe/boot.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/frappe/boot.py b/frappe/boot.py index 8585bddf90..8eed64b2dc 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -3,12 +3,11 @@ """ bootstrap client session """ -from typing import TYPE_CHECKING - import frappe import frappe.defaults import frappe.desk.desk_page from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo, get_navbar_settings +from frappe.database.utils import Query from frappe.desk.doctype.route_history.route_history import frequently_visited_links from frappe.desk.form.load import get_meta_bundle from frappe.email.inbox import get_email_accounts @@ -26,9 +25,6 @@ from frappe.utils import add_user_info, cstr, get_time_zone from frappe.utils.change_log import get_versions from frappe.website.doctype.web_page_view.web_page_view import is_tracking_enabled -if TYPE_CHECKING: - from frappe.database.utils import Query - def get_bootinfo(): """build and return boot info""" @@ -264,9 +260,8 @@ def _run_with_permission_query(query: "Query", doctype: str) -> list[dict]: """ permission_query = DatabaseQuery(doctype, frappe.session.user).get_permission_query_conditions() if permission_query: - query = f"{query} AND {permission_query}" - - return frappe.db.sql(query, as_dict=True) # nosemgrep + return frappe.db.sql(f"{query} AND {permission_query}", as_dict=True) # nosemgrep + return query.run(as_dict=True) def load_translations(bootinfo): From 788d512a69639f3eeb826f897babed0ca6d8d537 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Jan 2023 11:33:00 +0530 Subject: [PATCH 151/407] chore(deps): bump cookiejar from 2.1.2 to 2.1.4 (#19737) Bumps [cookiejar](https://github.com/bmeck/node-cookiejar) from 2.1.2 to 2.1.4. - [Release notes](https://github.com/bmeck/node-cookiejar/releases) - [Commits](https://github.com/bmeck/node-cookiejar/commits) --- updated-dependencies: - dependency-name: cookiejar dependency-type: indirect ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 550ccbbded..d2ee8e62a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -742,9 +742,9 @@ cookie@^0.4.0, cookie@~0.4.1: integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== cookiejar@^2.1.0: - version "2.1.2" - resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.2.tgz#dd8a235530752f988f9a0844f3fc589e3111125c" - integrity sha512-Mw+adcfzPxcPeI+0WlvRrr/3lGVO0bD75SxX6811cxSh1Wbxx7xZBGK1eVtDf6si8rg2lhnUjsVLMFMfbRIuwA== + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== copy-anything@^2.0.1: version "2.0.3" From acb0dc38ae08189e55c86fbf05cdc6bf72a02251 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 24 Jan 2023 12:17:57 +0530 Subject: [PATCH 152/407] fix: Check if attr exists before checking permlevel --- frappe/model/document.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/model/document.py b/frappe/model/document.py index c970170a6c..8a99676b60 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -675,14 +675,15 @@ class Document(BaseDocument): has_access_to = self.get_permlevel_access("read") for df in self.meta.fields: - if df.permlevel and df.permlevel not in has_access_to: + if df.permlevel and hasattr(self, df.fieldname) and df.permlevel not in has_access_to: delattr(self, df.fieldname) for table_field in self.meta.get_table_fields(): for df in frappe.get_meta(table_field.options).fields or []: if df.permlevel and df.permlevel not in has_access_to: for child in self.get(table_field.fieldname) or []: - delattr(child, df.fieldname) + if hasattr(child, df.fieldname): + delattr(child, df.fieldname) def validate_higher_perm_levels(self): """If the user does not have permissions at permlevel > 0, then reset the values to original / default""" From 1d97023b34cbf20528be0d0e7a64022330647d33 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Tue, 24 Jan 2023 12:33:18 +0530 Subject: [PATCH 153/407] fix(UX): Grid in 3 column layout is broken --- frappe/public/scss/common/grid.scss | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/frappe/public/scss/common/grid.scss b/frappe/public/scss/common/grid.scss index b3781d5501..775bf90704 100644 --- a/frappe/public/scss/common/grid.scss +++ b/frappe/public/scss/common/grid.scss @@ -43,16 +43,18 @@ } } -// hide row index in 6 column child tables -.form-column.col-sm-6 .form-grid { - .row-index { - display: none; - } - - .btn-open-row { - .edit-grid-row { +// hide row index in 6/4 column child tables +.form-column.col-sm-6, .form-column.col-sm-4 { + .form-grid { + .row-index { display: none; } + + .btn-open-row { + .edit-grid-row { + display: none; + } + } } } From 479e0c1439ce7f7165fc9b285fe131b1dd5f2a64 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 24 Jan 2023 12:39:55 +0530 Subject: [PATCH 154/407] fix: clear cache on every update in notifications This feels like duplicate but ensures that it gets cleared in every case. E.g. Some class might have overridden validate or ignore_validate might be set in which case cache doesn't get cleared. --- frappe/email/doctype/notification/notification.py | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/email/doctype/notification/notification.py b/frappe/email/doctype/notification/notification.py index 41fdfeeda1..ce19fb7b07 100644 --- a/frappe/email/doctype/notification/notification.py +++ b/frappe/email/doctype/notification/notification.py @@ -45,6 +45,7 @@ class Notification(Document): frappe.cache().hdel("notifications", self.document_type) def on_update(self): + frappe.cache().hdel("notifications", self.document_type) path = export_module_json(self, self.is_standard, self.module) if path: # js From be7fd7a58d0ffe8210f09c0bc8c1c849f8ac574d Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 24 Jan 2023 12:01:00 +0530 Subject: [PATCH 155/407] fix: point to custom field link instead --- frappe/desk/form/meta.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index 8b1408f1b5..90d20c8fc4 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -11,6 +11,7 @@ from frappe.model.utils import render_include from frappe.modules import get_module_path, load_doctype_module, scrub from frappe.translate import extract_messages_from_code, make_dict_from_messages from frappe.utils import get_html_format +from frappe.utils.data import get_link_to_form ASSET_KEYS = ( "__js", @@ -199,14 +200,19 @@ class FormMeta(Meta): # customizations are removed or some custom app is removed but hasn't cleaned # up after itself. frappe.clear_last_message() - customize_form_link = f'Customize Form' - frappe.throw( - _( - "Field {0} is referring to non-existing doctype {1}, please remove the field from {2} or add the required doctype." - ).format(frappe.bold(df.fieldname), frappe.bold(df.options), customize_form_link), - title=_("Missing DocType"), + + msg = _("Field {0} is referring to non-existing doctype {1}.").format( + frappe.bold(df.fieldname), frappe.bold(df.options) ) + if df.get("is_custom_field"): + custom_field_link = get_link_to_form("Custom Field", df.name) + msg += " " + _("Please delete the field from {2} or add the required doctype.").format( + custom_field_link + ) + + frappe.throw(msg, title=_("Missing DocType")) + def add_linked_document_type(self): for df in self.get("fields", {"fieldtype": "Link"}): if df.options: From 550261b3dcbc7ecec04fb9b8d5eddce203fefa23 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 24 Jan 2023 13:01:05 +0530 Subject: [PATCH 156/407] fix(db_query): Set & use existing constants --- frappe/model/base_document.py | 2 +- frappe/model/db_query.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 9b3bf27417..6fb525692f 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -353,7 +353,7 @@ class BaseDocument: if ignore_nulls and d[fieldname] is None: del d[fieldname] - if not is_virtual_field and field_value is _DOC_DELETED_ATTR: + elif not is_virtual_field and field_value is _DOC_DELETED_ATTR: del d[fieldname] return d diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index c7878f703b..3df23ce426 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -14,7 +14,7 @@ import frappe.share from frappe import _ from frappe.core.doctype.server_script.server_script_utils import get_server_script_map from frappe.database.utils import FallBackDateTimeStr, NestedSetHierarchy -from frappe.model import core_doctypes_list, optional_fields +from frappe.model import child_table_fields, core_doctypes_list, optional_fields from frappe.model.meta import get_table_columns from frappe.model.utils.user_settings import get_user_settings, update_user_settings from frappe.query_builder.utils import Column @@ -51,6 +51,7 @@ STRICT_FIELD_PATTERN = re.compile(r".*/\*.*") STRICT_UNION_PATTERN = re.compile(r".*\s(union).*\s") ORDER_GROUP_PATTERN = re.compile(r".*[^a-z0-9-_ ,`'\"\.\(\)].*") FN_PARAMS_PATTERN = re.compile(r".*?\((.*)\).*") +SPECIAL_FIELD_CHARS = frozenset(("(", "`", ".", "'", '"', "*")) class DatabaseQuery: @@ -1238,7 +1239,7 @@ def get_permitted_fields(doctype, parenttype=None): meta_fields.remove("docstatus") if meta.istable: - meta_fields.extend(["parent", "parenttype", "parentfield"]) + meta_fields.extend(child_table_fields) else: meta_fields.remove("idx") @@ -1253,7 +1254,7 @@ def wrap_grave_quotes(table: str) -> str: def is_plain_field(field: str) -> bool: for char in field: - if char in ("(", "`", ".", "'", '"', "*"): + if char in SPECIAL_FIELD_CHARS: return False return True From 5c5bd2b104bba0a517e0bf4160103008e4352370 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 24 Jan 2023 13:09:43 +0530 Subject: [PATCH 157/407] refactor: Meta.get_permitted_fieldnames * Remove older API that returned list[df] * Rename fields in usage scope & Meta internals --- frappe/model/db_query.py | 3 +-- frappe/model/meta.py | 12 ++++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 3df23ce426..6781c2f84b 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -1228,7 +1228,6 @@ def get_permitted_fields(doctype, parenttype=None): if doctype in core_doctypes_list: return meta.get_valid_columns() - accessible_fields = [x.fieldname for x in meta.get_permlevel_read_fields(parenttype=parenttype)] meta_fields = meta.default_fields.copy() optional_meta_fields = list(optional_fields) @@ -1243,7 +1242,7 @@ def get_permitted_fields(doctype, parenttype=None): else: meta_fields.remove("idx") - return meta_fields + accessible_fields + optional_meta_fields + return meta_fields + meta.get_permitted_fieldnames(parenttype=parenttype) + optional_meta_fields def wrap_grave_quotes(table: str) -> str: diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 7ef037521f..cfe43d8728 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -530,17 +530,17 @@ class Meta(Document): return self.high_permlevel_fields - def get_permlevel_read_fields(self, parenttype=None, *, user=None): - """Build list of fields with read perm level and all the higher perm levels defined.""" - if not hasattr(self, "permlevel_read_fields"): - self.permlevel_read_fields = [] + def get_permitted_fieldnames(self, parenttype=None, *, user=None): + """Build list of `fieldname` with read perm level and all the higher perm levels defined.""" + if not hasattr(self, "permitted_fieldnames"): + self.permitted_fieldnames = [] permlevel_access = set(self.get_permlevel_access("read", parenttype, user=user)) for df in self.get_fieldnames_with_value(with_field_meta=True): if df.permlevel in permlevel_access: - self.permlevel_read_fields.append(df) + self.permitted_fieldnames.append(df.fieldname) - return self.permlevel_read_fields + return self.permitted_fieldnames def get_permlevel_access(self, permission_type="read", parenttype=None, *, user=None): has_access_to = [] From 54ff630c77bd50005a88bd9d3fdca6f9c831643e Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 24 Jan 2023 13:27:07 +0530 Subject: [PATCH 158/407] fix(db_query): Permit optional_fields without checking in permitted fields --- frappe/model/db_query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 6781c2f84b..cb6d9b48c3 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -585,7 +585,7 @@ class DatabaseQuery: continue # labels / pseudo columns or frappe internals - elif column[0] in {"'", '"', "_"}: + elif column[0] in {"'", '"'} or column in optional_fields: continue # handle child / joined table fields From 2ae6b7f016506acea8b84de168ca13823e021d72 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 24 Jan 2023 13:52:33 +0530 Subject: [PATCH 159/407] fix: Handle * fields after field iterations --- frappe/model/db_query.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index cb6d9b48c3..10552986f2 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -562,6 +562,7 @@ class DatabaseQuery: if self.flags.ignore_permissions: return + asterisk_fields = [] permitted_fields = get_permitted_fields(doctype=self.doctype) for i, field in enumerate(self.fields): @@ -577,7 +578,7 @@ class DatabaseQuery: column = strip_alias(column).replace("`", "") if column == "*" and not in_function("*", field): - self.fields[i : i + 1] = permitted_fields + asterisk_fields.append(i) continue # handle pseudo columns @@ -626,6 +627,12 @@ class DatabaseQuery: else: self.fields.remove(field) + # handle * fields + j = 0 + for i in asterisk_fields: + self.fields[i + j : i + j + 1] = permitted_fields + j = j + len(permitted_fields) - 1 + def prepare_filter_condition(self, f): """Returns a filter condition in the format: ifnull(`tabDocType`.`fieldname`, fallback) operator "value" From e41f005daad1da250524f31e03a808bbf5ac485e Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 24 Jan 2023 13:59:48 +0530 Subject: [PATCH 160/407] fix(db_query): Remove naive field in sql func check --- frappe/model/db_query.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 10552986f2..7d797505f7 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -612,8 +612,6 @@ class DatabaseQuery: elif "(" in field: if "*" in field: continue - elif any(x for x in permitted_fields if x in field): - continue elif _params := FN_PARAMS_PATTERN.findall(column): params = (x for x in _params[0].split(",")) for param in params: From 0173d2abfbc0f59c8e74da0a25f22896dcc2d678 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Tue, 24 Jan 2023 17:56:10 +0530 Subject: [PATCH 161/407] test: fixed failing LDAP test --- frappe/integrations/doctype/ldap_settings/test_ldap_settings.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py index 9080e0c82a..fd54d12af2 100644 --- a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py @@ -182,6 +182,8 @@ class LDAP_TestCase: with contextlib.suppress(MandatoryError, ValidationError): frappe.get_doc(localdoc).save() + if mandatory_field == "default_role" and localdoc["default_user_type"] == "System User": + continue self.fail(f"Document LDAP Settings field [{mandatory_field}] is not mandatory") for non_mandatory_field in self.doc: # Ensure remaining fields have not been made mandatory From 8aa8ea0ee223a2f459f0bd1e72d057c1a9afa9aa Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 24 Jan 2023 13:46:57 +0100 Subject: [PATCH 162/407] feat: bump zxcvbn version zxcvbn 4.4.28 no longer crashes on long, random passwords. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 484fd13d1b..bd4628b504 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ dependencies = [ "terminaltables~=3.1.0", "traceback-with-variables~=2.0.4", "xlrd~=2.0.1", - "zxcvbn-python~=4.4.24", + "zxcvbn-python~=4.4.28", "markdownify~=0.11.2", # integration dependencies From d5a72b16ce5720265351780f92c4ac85c9e615de Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 24 Jan 2023 13:48:07 +0100 Subject: [PATCH 163/407] fix: trim long passwords before check In order for the check to pass in a reasonable amount of time. --- frappe/utils/password_strength.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frappe/utils/password_strength.py b/frappe/utils/password_strength.py index 59c784e5b4..f9d82268e4 100644 --- a/frappe/utils/password_strength.py +++ b/frappe/utils/password_strength.py @@ -12,6 +12,12 @@ from frappe import _ def test_password_strength(password, user_inputs=None): """Wrapper around zxcvbn.password_strength""" + if len(password) > 128: + # zxcvbn takes forever when checking long, random passwords. + # repetion patterns or user inputs in the first 128 characters + # will still be checked. + password = password[:128] + result = zxcvbn(password, user_inputs) result.update({"feedback": get_feedback(result.get("score"), result.get("sequence"))}) return result From e61d87827b728e5fa53128d4e5c6a50191b49049 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 24 Jan 2023 13:50:17 +0100 Subject: [PATCH 164/407] refactor: imports - remove unused imports - import only the required patterns, not the entire file --- frappe/utils/password_strength.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/frappe/utils/password_strength.py b/frappe/utils/password_strength.py index f9d82268e4..9b32e168bb 100644 --- a/frappe/utils/password_strength.py +++ b/frappe/utils/password_strength.py @@ -1,10 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -try: - from zxcvbn import zxcvbn -except Exception: - import zxcvbn +from zxcvbn import zxcvbn +from zxcvbn.scoring import ALL_UPPER, START_UPPER import frappe from frappe import _ @@ -27,13 +25,7 @@ def test_password_strength(password, user_inputs=None): # ------------------------------------------- # feedback functionality code from https://github.com/sans-serif/python-zxcvbn/blob/master/zxcvbn/feedback.py # see license for feedback code at https://github.com/sans-serif/python-zxcvbn/blob/master/LICENSE.txt - -# Used for regex matching capitalization -import re - -# Used to get the regex patterns for capitalization -# (Used the same way in the original zxcvbn) -from zxcvbn import scoring +# ------------------------------------------- # Default feedback value default_feedback = { @@ -183,9 +175,9 @@ def get_dictionary_match_feedback(match, is_sole_match): word = match.get("token") # Variations of the match like UPPERCASES - if scoring.START_UPPER.match(word): + if START_UPPER.match(word): suggestions.append(_("Capitalization doesn't help very much.")) - elif scoring.ALL_UPPER.match(word): + elif ALL_UPPER.match(word): suggestions.append(_("All-uppercase is almost as easy to guess as all-lowercase.")) # Match contains l33t speak substitutions From 2148dc745e4027e290caccdbd84cf544dc4a638c Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 24 Jan 2023 13:51:47 +0100 Subject: [PATCH 165/407] refactor: assign instead of update --- frappe/utils/password_strength.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/utils/password_strength.py b/frappe/utils/password_strength.py index 9b32e168bb..eada58c638 100644 --- a/frappe/utils/password_strength.py +++ b/frappe/utils/password_strength.py @@ -17,7 +17,7 @@ def test_password_strength(password, user_inputs=None): password = password[:128] result = zxcvbn(password, user_inputs) - result.update({"feedback": get_feedback(result.get("score"), result.get("sequence"))}) + result["feedback"] = get_feedback(result.get("score"), result.get("sequence")) return result From b4ff826711ffdf596e8c76b125922904685bf439 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Tue, 24 Jan 2023 18:30:51 +0530 Subject: [PATCH 166/407] fix: default_user_type should be Website User if not set --- frappe/integrations/doctype/ldap_settings/ldap_settings.py | 2 +- .../integrations/doctype/ldap_settings/test_ldap_settings.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.py b/frappe/integrations/doctype/ldap_settings/ldap_settings.py index 094c440672..21e5c5b312 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: class LDAPSettings(Document): def validate(self): - self.default_user_type = self.default_user_type or "System User" + self.default_user_type = self.default_user_type or "Website User" if not self.enabled: return diff --git a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py index fd54d12af2..0417ea30e4 100644 --- a/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/test_ldap_settings.py @@ -173,7 +173,6 @@ class LDAP_TestCase: "ldap_username_field", "ldap_first_name_field", "require_trusted_certificate", - "default_role", ] # fields that are required to have ldap functioning need to be mandatory for mandatory_field in mandatory_fields: @@ -182,8 +181,6 @@ class LDAP_TestCase: with contextlib.suppress(MandatoryError, ValidationError): frappe.get_doc(localdoc).save() - if mandatory_field == "default_role" and localdoc["default_user_type"] == "System User": - continue self.fail(f"Document LDAP Settings field [{mandatory_field}] is not mandatory") for non_mandatory_field in self.doc: # Ensure remaining fields have not been made mandatory From 92e684d4fcbed6aa67b9ba2621275b0f3ec2af79 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 24 Jan 2023 14:07:34 +0100 Subject: [PATCH 167/407] fix: use new source for zxcvbn --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bd4628b504..965990e028 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,7 +69,7 @@ dependencies = [ "terminaltables~=3.1.0", "traceback-with-variables~=2.0.4", "xlrd~=2.0.1", - "zxcvbn-python~=4.4.28", + "zxcvbn~=4.4.28", "markdownify~=0.11.2", # integration dependencies From d357af153379078ad85e48db0e36be5e97d410c7 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 24 Jan 2023 17:45:55 +0530 Subject: [PATCH 168/407] refactor: Add a maxsplit limit to string splits --- frappe/__init__.py | 2 +- frappe/auth.py | 4 +++- frappe/commands/utils.py | 4 ++-- .../doctype/communication/communication.py | 4 ++-- frappe/core/doctype/doctype/doctype.py | 6 +++--- frappe/core/doctype/file/utils.py | 4 ++-- .../doctype/package_import/package_import.py | 2 +- .../user_permission/test_user_permission.py | 2 +- frappe/database/mariadb/setup_db.py | 2 +- frappe/desk/doctype/event/event.py | 18 ++++++++++++------ frappe/desk/form/meta.py | 4 ++-- frappe/desk/query_report.py | 2 +- frappe/desk/reportview.py | 6 +++--- frappe/desk/search.py | 2 +- frappe/frappeclient.py | 6 +++++- frappe/installer.py | 4 ++-- frappe/model/create_new.py | 2 +- frappe/model/db_query.py | 6 +++--- frappe/model/delete_doc.py | 2 +- frappe/model/utils/rename_field.py | 2 +- frappe/modules/import_file.py | 2 +- frappe/modules/patch_handler.py | 4 ++-- frappe/search/website_search.py | 2 +- frappe/tests/test_commands.py | 2 +- frappe/tests/test_patches.py | 4 ++-- frappe/tests/test_search.py | 2 +- frappe/translate.py | 2 +- frappe/utils/__init__.py | 2 +- frappe/utils/backups.py | 2 +- frappe/utils/change_log.py | 2 +- frappe/utils/data.py | 4 ++-- frappe/utils/dateutils.py | 2 +- frappe/utils/error.py | 2 +- frappe/utils/global_search.py | 2 +- frappe/website/doctype/blogger/blogger.py | 2 +- .../test_personal_data_download_request.py | 2 +- frappe/website/doctype/web_page/web_page.py | 2 +- .../doctype/web_page_view/web_page_view.py | 2 +- 38 files changed, 69 insertions(+), 57 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index 52aa734f8a..7cffb9a512 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -1590,7 +1590,7 @@ def read_file(path, raise_not_found=False): def get_attr(method_string: str) -> Any: """Get python method object from its name.""" - app_name = method_string.split(".")[0] + app_name = method_string.split(".", 1)[0] if ( not local.flags.in_uninstall and not local.flags.in_install diff --git a/frappe/auth.py b/frappe/auth.py index d1dc10817c..3321784ce2 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -55,7 +55,9 @@ class HTTPRequest: def set_request_ip(self): if frappe.get_request_header("X-Forwarded-For"): - frappe.local.request_ip = (frappe.get_request_header("X-Forwarded-For").split(",")[0]).strip() + frappe.local.request_ip = ( + frappe.get_request_header("X-Forwarded-For").split(",", 1)[0] + ).strip() elif frappe.get_request_header("REMOTE_ADDR"): frappe.local.request_ip = frappe.get_request_header("REMOTE_ADDR") diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index f4f2cd8744..280e656f1c 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -562,7 +562,7 @@ def _psql(): def jupyter(context): """Start an interactive jupyter notebook""" installed_packages = ( - r.split("==")[0] + r.split("==", 1)[0] for r in subprocess.check_output([sys.executable, "-m", "pip", "freeze"], encoding="utf8") ) @@ -1001,7 +1001,7 @@ def request(context, args=None, path=None): frappe.local.form_dict = frappe._dict() if args.startswith("/api/method"): - frappe.local.form_dict.cmd = args.split("?")[0].split("/")[-1] + frappe.local.form_dict.cmd = args.split("?", 1)[0].split("/")[-1] elif path: with open(os.path.join("..", path)) as f: args = json.loads(f.read()) diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 9944961ca9..9756bc73c0 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -499,7 +499,7 @@ def parse_email(communication, email_strings): if email_string: for email in email_string.split(","): if delimiter in email: - email = email.split("@")[0] + email = email.split("@", 1)[0] email_local_parts = email.split(delimiter) if not len(email_local_parts) == 3: continue @@ -521,7 +521,7 @@ def get_email_without_link(email): try: _email = email.split("@") - email_id = _email[0].split("+")[0] + email_id = _email[0].split("+", 1)[0] email_host = _email[1] except IndexError: return email diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index a3d42ed4ec..64b6f3123d 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -885,7 +885,7 @@ def validate_series(dt, autoname=None, name=None): if not autoname and dt.get("fields", {"fieldname": "naming_series"}): dt.autoname = "naming_series:" elif dt.autoname and dt.autoname.startswith("naming_series:"): - fieldname = dt.autoname.split("naming_series:")[0] or "naming_series" + fieldname = dt.autoname.split("naming_series:", 1)[0] or "naming_series" if not dt.get("fields", {"fieldname": fieldname}): frappe.throw( _("Fieldname called {0} must exist to enable autonaming").format(frappe.bold(fieldname)), @@ -913,7 +913,7 @@ def validate_series(dt, autoname=None, name=None): and (not autoname.startswith("format:")) ): - prefix = autoname.split(".")[0] + prefix = autoname.split(".", 1)[0] doctype = frappe.qb.DocType("DocType") used_in = ( frappe.qb.from_(doctype) @@ -1348,7 +1348,7 @@ def validate_fields(meta): if meta.sort_field: sort_fields = [meta.sort_field] if "," in meta.sort_field: - sort_fields = [d.split()[0] for d in meta.sort_field.split(",")] + sort_fields = [d.split(maxsplit=1)[0] for d in meta.sort_field.split(",")] for fieldname in sort_fields: if fieldname not in (fieldname_list + list(default_fields) + list(child_table_fields)): diff --git a/frappe/core/doctype/file/utils.py b/frappe/core/doctype/file/utils.py index d99e5cff48..17a092e340 100644 --- a/frappe/core/doctype/file/utils.py +++ b/frappe/core/doctype/file/utils.py @@ -225,7 +225,7 @@ def extract_images_from_html(doc: "Document", content: str, is_private: bool = F def _save_file(match): data = match.group(1).split("data:")[1] headers, content = data.split(",") - mtype = headers.split(";")[0] + mtype = headers.split(";", 1)[0] if isinstance(content, str): content = content.encode("utf-8") @@ -237,7 +237,7 @@ def extract_images_from_html(doc: "Document", content: str, is_private: bool = F if "filename=" in headers: filename = headers.split("filename=")[-1] - filename = safe_decode(filename).split(";")[0] + filename = safe_decode(filename).split(";", 1)[0] else: filename = get_random_filename(content_type=mtype) diff --git a/frappe/core/doctype/package_import/package_import.py b/frappe/core/doctype/package_import/package_import.py index 19762eae4a..4939b357b0 100644 --- a/frappe/core/doctype/package_import/package_import.py +++ b/frappe/core/doctype/package_import/package_import.py @@ -26,7 +26,7 @@ class PackageImport(Document): attachment = attachment[0] # get package_name from file (package_name-0.0.0.tar.gz) - package_name = attachment.file_name.split(".")[0].rsplit("-", 1)[0] + package_name = attachment.file_name.split(".", 1)[0].rsplit("-", 1)[0] if not os.path.exists(frappe.get_site_path("packages")): os.makedirs(frappe.get_site_path("packages")) diff --git a/frappe/core/doctype/user_permission/test_user_permission.py b/frappe/core/doctype/user_permission/test_user_permission.py index 10dc75ba39..8742d2e040 100644 --- a/frappe/core/doctype/user_permission/test_user_permission.py +++ b/frappe/core/doctype/user_permission/test_user_permission.py @@ -277,7 +277,7 @@ def create_user(email, *roles): user = frappe.new_doc("User") user.email = email - user.first_name = email.split("@")[0] + user.first_name = email.split("@", 1)[0] if not roles: roles = ("System Manager",) diff --git a/frappe/database/mariadb/setup_db.py b/frappe/database/mariadb/setup_db.py index 67f809abf7..add7fa373f 100644 --- a/frappe/database/mariadb/setup_db.py +++ b/frappe/database/mariadb/setup_db.py @@ -19,7 +19,7 @@ def get_mariadb_version(version_string: str = ""): # MariaDB classifies their versions as Major (1st and 2nd number), and Minor (3rd number) # Example: Version 10.3.13 is Major Version = 10.3, Minor Version = 13 version_string = version_string or get_mariadb_variables().get("version") - version = version_string.split("-")[0] + version = version_string.split("-", 1)[0] return version.rsplit(".", 1) diff --git a/frappe/desk/doctype/event/event.py b/frappe/desk/doctype/event/event.py index fafd317155..6ce356fc7d 100644 --- a/frappe/desk/doctype/event/event.py +++ b/frappe/desk/doctype/event/event.py @@ -306,8 +306,8 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[ ) # process recurring events - start = start.split(" ")[0] - end = end.split(" ")[0] + start = start.split(" ", 1)[0] + end = end.split(" ", 1)[0] add_events = [] remove_events = [] @@ -315,7 +315,7 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[ new_event = e.copy() enddate = ( - add_days(date, int(date_diff(e.ends_on.split(" ")[0], e.starts_on.split(" ")[0]))) + add_days(date, int(date_diff(e.ends_on.split(" ", 1)[0], e.starts_on.split(" ", 1)[0]))) if (e.starts_on and e.ends_on) else date ) @@ -337,8 +337,8 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[ repeat = "3000-01-01" if cstr(e.repeat_till) == "" else e.repeat_till if e.repeat_on == "Yearly": - start_year = cint(start.split("-")[0]) - end_year = cint(end.split("-")[0]) + start_year = cint(start.split("-", 1)[0]) + end_year = cint(end.split("-", 1)[0]) # creates a string with date (27) and month (07) eg: 07-27 event_start = "-".join(event_start.split("-")[1:]) @@ -357,7 +357,13 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[ if e.repeat_on == "Monthly": # creates a string with date (27) and month (07) and year (2019) eg: 2019-07-27 - date = start.split("-")[0] + "-" + start.split("-")[1] + "-" + event_start.split("-")[2] + date = ( + start.split("-", maxsplit=1)[0] + + "-" + + start.split("-", maxsplit=2)[1] + + "-" + + event_start.split("-", maxsplit=3)[2] + ) # last day of month issue, start from prev month! try: diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index 90d20c8fc4..3fd1b1edf3 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -134,7 +134,7 @@ class FormMeta(Meta): for fname in os.listdir(path): if fname.endswith(".html"): with open(os.path.join(path, fname), encoding="utf-8") as f: - templates[fname.split(".")[0]] = scrub_html_template(f.read()) + templates[fname.split(".", 1)[0]] = scrub_html_template(f.read()) self.set("__templates", templates or None) @@ -249,7 +249,7 @@ class FormMeta(Meta): def load_templates(self): if not self.custom: module = load_doctype_module(self.name) - app = module.__name__.split(".")[0] + app = module.__name__.split(".", 1)[0] templates = {} if hasattr(module, "form_grid_templates"): for key, path in module.form_grid_templates.items(): diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py index b4a51ffaf3..7abd6657e5 100644 --- a/frappe/desk/query_report.py +++ b/frappe/desk/query_report.py @@ -428,7 +428,7 @@ def add_total_row(result, columns, meta=None, is_tree=False, parent_field=None): if isinstance(columns[0], str): first_col = columns[0].split(":") if len(first_col) > 1: - first_col_fieldtype = first_col[1].split("/")[0] + first_col_fieldtype = first_col[1].split("/", 1)[0] else: first_col_fieldtype = columns[0].get("fieldtype") diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index 8f929311e0..d68c1d36fe 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -185,7 +185,7 @@ def extract_fieldname(field): fieldname = field for sep in (" as ", " AS "): if sep in fieldname: - fieldname = fieldname.split(sep)[0] + fieldname = fieldname.split(sep, 1)[0] # certain functions allowed, extract the fieldname from the function if fieldname.startswith("count(") or fieldname.startswith("sum(") or fieldname.startswith("avg("): @@ -456,13 +456,13 @@ def handle_duration_fieldtype_values(doctype, data, fields): def parse_field(field: str) -> tuple[str | None, str]: """Parse a field into parenttype and fieldname.""" - key = field.split(" as ")[0] + key = field.split(" as ", 1)[0] if key.startswith(("count(", "sum(", "avg(")): raise ValueError if "." in key: - return key.split(".")[0][4:-1], key.split(".")[1].strip("`") + return key.split(".", 1)[0][4:-1], key.split(".", 2)[1].strip("`") return None, key.strip("`") diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 446f842a0b..2af9b575be 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -76,7 +76,7 @@ def search_widget( standard_queries = frappe.get_hooks().standard_queries or {} - if query and query.split()[0].lower() != "select": + if query and query.split(maxsplit=1)[0].lower() != "select": # by method try: is_whitelisted(frappe.get_attr(query)) diff --git a/frappe/frappeclient.py b/frappe/frappeclient.py index ec5f205197..3f7577fac6 100644 --- a/frappe/frappeclient.py +++ b/frappe/frappeclient.py @@ -288,7 +288,11 @@ class FrappeClient: if doctype != "User" and not frappe.db.exists("User", doc.get("owner")): frappe.get_doc( - {"doctype": "User", "email": doc.get("owner"), "first_name": doc.get("owner").split("@")[0]} + { + "doctype": "User", + "email": doc.get("owner"), + "first_name": doc.get("owner").split("@", 1)[0], + } ).insert() if update: diff --git a/frappe/installer.py b/frappe/installer.py index 0016be3699..9c2807d7cd 100644 --- a/frappe/installer.py +++ b/frappe/installer.py @@ -242,7 +242,7 @@ def parse_app_name(name: str) -> str: _repo = name.split(":")[1].rsplit("/", 1)[1] else: _repo = name.rsplit("/", 2)[2] - repo = _repo.split(".")[0] + repo = _repo.split(".", 1)[0] else: _, repo, _ = fetch_details_from_tag(name) return repo @@ -785,7 +785,7 @@ def is_downgrade(sql_file_path, verbose=False): for app in all_apps: app_name = app[0] - app_version = app[1].split(" ")[0] + app_version = app[1].split(" ", 1)[0] if app_name == "frappe": try: diff --git a/frappe/model/create_new.py b/frappe/model/create_new.py index 51810c3e18..f8b7a73a3b 100644 --- a/frappe/model/create_new.py +++ b/frappe/model/create_new.py @@ -115,7 +115,7 @@ def get_static_default_value(df, doctype_user_permissions, allowed_records): return df.default elif df.fieldtype == "Select" and df.options and df.options not in ("[Select]", "Loading..."): - return df.options.split("\n")[0] + return df.options.split("\n", 1)[0] def validate_value_via_user_permissions( diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 1d156d0d1a..99b07c199d 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -435,7 +435,7 @@ class DatabaseQuery: if not ("tab" in field and "." in field) or any(x for x in sql_functions if x in field): continue - table_name = field.split(".")[0] + table_name = field.split(".", 1)[0] if table_name.lower().startswith("group_concat("): table_name = table_name[13:] @@ -897,7 +897,7 @@ class DatabaseQuery: # will covert to # `tabItem`.`idx` desc, `tabItem`.`modified` desc args.order_by = ", ".join( - f"`tab{self.doctype}`.`{f.split()[0].strip()}` {f.split()[1].strip()}" + f"`tab{self.doctype}`.`{f.split(maxsplit=1)[0].strip()}` {f.split(maxsplit=2)[1].strip()}" for f in meta.sort_field.split(",") ) else: @@ -1029,7 +1029,7 @@ def get_order_by(doctype, meta): # will covert to # `tabItem`.`idx` desc, `tabItem`.`modified` desc order_by = ", ".join( - f"`tab{doctype}`.`{f.split()[0].strip()}` {f.split()[1].strip()}" + f"`tab{doctype}`.`{f.split(maxsplit=1)[0].strip()}` {f.split(maxsplit=2)[1].strip()}" for f in meta.sort_field.split(",") ) diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py index 48eaa63460..bfad833d38 100644 --- a/frappe/model/delete_doc.py +++ b/frappe/model/delete_doc.py @@ -176,7 +176,7 @@ def update_naming_series(doc): if doc.meta.autoname.startswith("naming_series:") and getattr(doc, "naming_series", None): revert_series_if_last(doc.naming_series, doc.name, doc) - elif doc.meta.autoname.split(":")[0] not in ("Prompt", "field", "hash", "autoincrement"): + elif doc.meta.autoname.split(":", 1)[0] not in ("Prompt", "field", "hash", "autoincrement"): revert_series_if_last(doc.meta.autoname, doc.name, doc) diff --git a/frappe/model/utils/rename_field.py b/frappe/model/utils/rename_field.py index 9e4fc5d84a..c17d01183b 100644 --- a/frappe/model/utils/rename_field.py +++ b/frappe/model/utils/rename_field.py @@ -27,7 +27,7 @@ def rename_field(doctype, old_fieldname, new_fieldname): frappe.db.sql( """update `tab%s` set parentfield=%s where parentfield=%s""" - % (new_field.options.split("\n")[0], "%s", "%s"), + % (new_field.options.split("\n", 1)[0], "%s", "%s"), (new_fieldname, old_fieldname), ) diff --git a/frappe/modules/import_file.py b/frappe/modules/import_file.py index 3690da0657..36e329409a 100644 --- a/frappe/modules/import_file.py +++ b/frappe/modules/import_file.py @@ -252,7 +252,7 @@ def load_code_properties(doc, path): if hasattr(doc, "get_code_fields"): dirname, filename = os.path.split(path) for key, extn in doc.get_code_fields().items(): - codefile = os.path.join(dirname, filename.split(".")[0] + "." + extn) + codefile = os.path.join(dirname, filename.split(".", 1)[0] + "." + extn) if os.path.exists(codefile): with open(codefile) as txtfile: doc.set(key, txtfile.read()) diff --git a/frappe/modules/patch_handler.py b/frappe/modules/patch_handler.py index 15144a1630..230c1547c6 100644 --- a/frappe/modules/patch_handler.py +++ b/frappe/modules/patch_handler.py @@ -152,7 +152,7 @@ def run_single(patchmodule=None, method=None, methodargs=None, force=False): return True -def execute_patch(patchmodule, method=None, methodargs=None): +def execute_patch(patchmodule: str, method=None, methodargs=None): """execute the patch""" _patch_mode(True) @@ -162,7 +162,7 @@ def execute_patch(patchmodule, method=None, methodargs=None): docstring = "" else: has_patch_file = True - patch = f"{patchmodule.split()[0]}.execute" + patch = f"{patchmodule.split(maxsplit=1)[0]}.execute" _patch = frappe.get_attr(patch) docstring = _patch.__doc__ or "" diff --git a/frappe/search/website_search.py b/frappe/search/website_search.py index 9af827aaa8..2b35b86de7 100644 --- a/frappe/search/website_search.py +++ b/frappe/search/website_search.py @@ -123,7 +123,7 @@ def get_static_pages_from_all_apps(): files_to_index = glob(path_to_index + "/**/*.html", recursive=True) files_to_index.extend(glob(path_to_index + "/**/*.md", recursive=True)) for file in files_to_index: - route = os.path.relpath(file, path_to_index).split(".")[0] + route = os.path.relpath(file, path_to_index).split(".", maxsplit=1)[0] if route.endswith("index"): route = route.rsplit("index", 1)[0] routes_to_index.append(route) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index f8f3921440..4a10484d1d 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -330,7 +330,7 @@ class TestCommands(BaseTestCommands): # test 2: bare functionality for single site self.execute("bench --site {site} list-apps") self.assertEqual(self.returncode, 0) - list_apps = {_x.split()[0] for _x in self.stdout.split("\n")} + list_apps = {_x.split(maxsplit=1)[0] for _x in self.stdout.split("\n")} doctype = frappe.get_single("Installed Applications").installed_applications if doctype: installed_apps = {x.app_name for x in doctype} diff --git a/frappe/tests/test_patches.py b/frappe/tests/test_patches.py index f12f3a182c..99fe76ce84 100644 --- a/frappe/tests/test_patches.py +++ b/frappe/tests/test_patches.py @@ -59,7 +59,7 @@ class TestPatches(FrappeTestCase): else: if patchmodule.startswith("finally:"): patchmodule = patchmodule.split("finally:")[-1] - self.assertTrue(frappe.get_attr(patchmodule.split()[0] + ".execute")) + self.assertTrue(frappe.get_attr(patchmodule.split(maxsplit=1)[0] + ".execute")) frappe.flags.in_install = False @@ -149,7 +149,7 @@ def check_patch_files(app): patch_dir = Path(frappe.get_app_path(app)) / "patches" - app_patches = [p.split()[0] for p in patch_handler.get_patches_from_app(app)] + app_patches = [p.split(maxsplit=1)[0] for p in patch_handler.get_patches_from_app(app)] missing_patches = [] diff --git a/frappe/tests/test_search.py b/frappe/tests/test_search.py index 24bd8b8057..fdcf005da8 100644 --- a/frappe/tests/test_search.py +++ b/frappe/tests/test_search.py @@ -54,7 +54,7 @@ class TestSearch(FrappeTestCase): user.update( { "email": email, - "first_name": email.split("@")[0], + "first_name": email.split("@", 1)[0], "enabled": False, "allowed_in_mentions": True, } diff --git a/frappe/translate.py b/frappe/translate.py index 464b6e064c..5179daa545 100644 --- a/frappe/translate.py +++ b/frappe/translate.py @@ -314,7 +314,7 @@ def get_translations_from_apps(lang, apps=None): path = os.path.join(frappe.get_pymodule_path(app), "translations", lang + ".csv") translations.update(get_translation_dict_from_file(path, lang, app) or {}) if "-" in lang: - parent = lang.split("-")[0] + parent = lang.split("-", 1)[0] parent_translations = get_translations_from_apps(parent) parent_translations.update(translations) return parent_translations diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index 47f083b638..c715097be2 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -509,7 +509,7 @@ def decode_dict(d, encoding="utf-8"): @functools.lru_cache def get_site_name(hostname): - return hostname.split(":")[0] + return hostname.split(":", 1)[0] def get_disk_usage(): diff --git a/frappe/utils/backups.py b/frappe/utils/backups.py index 1035c111a5..10e7cbc1a5 100644 --- a/frappe/utils/backups.py +++ b/frappe/utils/backups.py @@ -265,7 +265,7 @@ class BackupGenerator: def backup_time(file_path): file_name = file_path.split(os.sep)[-1] - file_timestamp = file_name.split("-")[0] + file_timestamp = file_name.split("-", 1)[0] return timegm(datetime.strptime(file_timestamp, "%Y%m%d_%H%M%S").utctimetuple()) def get_latest(file_pattern): diff --git a/frappe/utils/change_log.py b/frappe/utils/change_log.py index 55534614e6..a4b56686c2 100644 --- a/frappe/utils/change_log.py +++ b/frappe/utils/change_log.py @@ -177,7 +177,7 @@ def check_for_update(): # Get local instance's current version or the app branch_version = ( - apps[app]["branch_version"].split(" ")[0] if apps[app].get("branch_version", "") else "" + apps[app]["branch_version"].split(" ", 1)[0] if apps[app].get("branch_version", "") else "" ) instance_version = Version(branch_version or apps[app].get("version")) # Compare and popup update message diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 3e2d3c0959..f17a6e59d0 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -1191,7 +1191,7 @@ def fmt_money( if flt(amount) < 0: minus = "-" - amount = cstr(abs(flt(amount))).split(".")[0] + amount = cstr(abs(flt(amount))).split(".", 1)[0] if len(amount) > 3: parts.append(amount[-3:]) @@ -1348,7 +1348,7 @@ def is_image(filepath: str) -> bool: from mimetypes import guess_type # filepath can be https://example.com/bed.jpg?v=129 - filepath = (filepath or "").split("?")[0] + filepath = (filepath or "").split("?", 1)[0] return (guess_type(filepath)[0] or "").startswith("image/") diff --git a/frappe/utils/dateutils.py b/frappe/utils/dateutils.py index ca147744d4..217ce59ea9 100644 --- a/frappe/utils/dateutils.py +++ b/frappe/utils/dateutils.py @@ -51,7 +51,7 @@ def parse_date(date): if " " in date: # as date-timestamp, remove the time part - date = date.split(" ")[0] + date = date.split(" ", 1)[0] # why the sorting? checking should be done in a predictable order check_formats = [None] + sorted( diff --git a/frappe/utils/error.py b/frappe/utils/error.py index 32a9e97e71..432591175c 100644 --- a/frappe/utils/error.py +++ b/frappe/utils/error.py @@ -68,7 +68,7 @@ def get_snapshot(exception, context=10): s = { "pyver": "Python {version:s}: {executable:s} (prefix: {prefix:s})".format( - version=sys.version.split()[0], executable=sys.executable, prefix=sys.prefix + version=sys.version.split(maxsplit=1)[0], executable=sys.executable, prefix=sys.prefix ), "timestamp": cstr(datetime.datetime.now()), "traceback": traceback.format_exc(), diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py index 391533edc0..5e5c1da141 100644 --- a/frappe/utils/global_search.py +++ b/frappe/utils/global_search.py @@ -307,7 +307,7 @@ def get_routes_to_index(): filepath = os.path.join(dirpath, f) route = os.path.relpath(filepath, base) - route = route.split(".")[0] + route = route.split(".", 1)[0] if route.endswith("index"): route = route.rsplit("index", 1)[0] diff --git a/frappe/website/doctype/blogger/blogger.py b/frappe/website/doctype/blogger/blogger.py index 9f348a2ea1..fc48bf2800 100644 --- a/frappe/website/doctype/blogger/blogger.py +++ b/frappe/website/doctype/blogger/blogger.py @@ -13,7 +13,7 @@ class Blogger(Document): if self.user and not frappe.db.exists("User", self.user): # for data import frappe.get_doc( - {"doctype": "User", "email": self.user, "first_name": self.user.split("@")[0]} + {"doctype": "User", "email": self.user, "first_name": self.user.split("@", 1)[0]} ).insert() def on_update(self): diff --git a/frappe/website/doctype/personal_data_download_request/test_personal_data_download_request.py b/frappe/website/doctype/personal_data_download_request/test_personal_data_download_request.py index ed054931e7..1a8bb1743f 100644 --- a/frappe/website/doctype/personal_data_download_request/test_personal_data_download_request.py +++ b/frappe/website/doctype/personal_data_download_request/test_personal_data_download_request.py @@ -61,7 +61,7 @@ def create_user_if_not_exists(email, first_name=None): "user_type": "Website User", "email": email, "send_welcome_email": 0, - "first_name": first_name or email.split("@")[0], + "first_name": first_name or email.split("@", 1)[0], "birth_date": frappe.utils.now_datetime(), } ).insert(ignore_permissions=True) diff --git a/frappe/website/doctype/web_page/web_page.py b/frappe/website/doctype/web_page/web_page.py index 2a02c28756..9a16654085 100644 --- a/frappe/website/doctype/web_page/web_page.py +++ b/frappe/website/doctype/web_page/web_page.py @@ -153,7 +153,7 @@ class WebPage(WebsiteGenerator): def check_for_redirect(self, context): if "")[0].strip() + context.main_section.split("", 1)[0].strip() ) raise frappe.Redirect diff --git a/frappe/website/doctype/web_page_view/web_page_view.py b/frappe/website/doctype/web_page_view/web_page_view.py index 31e36d3b1f..40c11782f5 100644 --- a/frappe/website/doctype/web_page_view/web_page_view.py +++ b/frappe/website/doctype/web_page_view/web_page_view.py @@ -18,7 +18,7 @@ def make_view_log(path, referrer=None, browser=None, version=None, url=None, use user_agent = request_dict.get("environ", {}).get("HTTP_USER_AGENT") if referrer: - referrer = referrer.split("?")[0] + referrer = referrer.split("?", 1)[0] is_unique = True if referrer.startswith(url): From a33e34519a5f0f4cde6022f2220d719b544db209 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Tue, 24 Jan 2023 15:04:47 +0100 Subject: [PATCH 169/407] feat: add test case for long passwords --- frappe/tests/test_password_strength.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 frappe/tests/test_password_strength.py diff --git a/frappe/tests/test_password_strength.py b/frappe/tests/test_password_strength.py new file mode 100644 index 0000000000..5dc87d185d --- /dev/null +++ b/frappe/tests/test_password_strength.py @@ -0,0 +1,18 @@ +import random +from string import printable +from time import time +from unittest import TestCase + +from frappe.utils.password_strength import test_password_strength + + +class TestPasswordStrength(TestCase): + def test_long_password(self): + password = "".join(random.choice(printable) for _ in range(600)) + + start_second = time() + result = test_password_strength(password) + end_second = time() + + self.assertLess(end_second - start_second, 10) + self.assertIn("feedback", result) From e75bfd0e73a6da9cb8d91adb6d1ac756f23ae1fc Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 24 Jan 2023 17:58:27 +0530 Subject: [PATCH 170/407] refactor: Split objects just once Co-authored-by: Ritwik Puri --- frappe/desk/doctype/event/event.py | 9 ++------- frappe/desk/reportview.py | 3 ++- frappe/model/db_query.py | 6 ++++-- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/frappe/desk/doctype/event/event.py b/frappe/desk/doctype/event/event.py index 6ce356fc7d..53a5a50cec 100644 --- a/frappe/desk/doctype/event/event.py +++ b/frappe/desk/doctype/event/event.py @@ -357,13 +357,8 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[ if e.repeat_on == "Monthly": # creates a string with date (27) and month (07) and year (2019) eg: 2019-07-27 - date = ( - start.split("-", maxsplit=1)[0] - + "-" - + start.split("-", maxsplit=2)[1] - + "-" - + event_start.split("-", maxsplit=3)[2] - ) + year, month = start.split("-", maxsplit=2)[:2] + date = f"{year}-{month}-" + event_start.split("-", maxsplit=3)[2] # last day of month issue, start from prev month! try: diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index d68c1d36fe..1b0d4172ea 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -462,7 +462,8 @@ def parse_field(field: str) -> tuple[str | None, str]: raise ValueError if "." in key: - return key.split(".", 1)[0][4:-1], key.split(".", 2)[1].strip("`") + table, column = key.split(".", 2)[:2] + return table[4:-1], column.strip("`") return None, key.strip("`") diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 99b07c199d..efdad83f13 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -897,8 +897,9 @@ class DatabaseQuery: # will covert to # `tabItem`.`idx` desc, `tabItem`.`modified` desc args.order_by = ", ".join( - f"`tab{self.doctype}`.`{f.split(maxsplit=1)[0].strip()}` {f.split(maxsplit=2)[1].strip()}" + f"`tab{self.doctype}`.`{f_split[0].strip()}` {f_split[1].strip()}" for f in meta.sort_field.split(",") + if (f_split := f.split(maxsplit=2)) ) else: sort_field = meta.sort_field or "modified" @@ -1029,8 +1030,9 @@ def get_order_by(doctype, meta): # will covert to # `tabItem`.`idx` desc, `tabItem`.`modified` desc order_by = ", ".join( - f"`tab{doctype}`.`{f.split(maxsplit=1)[0].strip()}` {f.split(maxsplit=2)[1].strip()}" + f"`tab{doctype}`.`{f_split[0].strip()}` {f_split[1].strip()}" for f in meta.sort_field.split(",") + if (f_split := f.split(maxsplit=2)) ) else: From 0fd3c5a0f00b48051c0520fb2a202df9164ddfc6 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Wed, 25 Jan 2023 06:26:11 +0000 Subject: [PATCH 171/407] perf(DX): use cached `Meta` to create `FormMeta` (#19736) --- frappe/desk/form/meta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index 3fd1b1edf3..94f3842ab7 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -52,7 +52,7 @@ def get_meta(doctype, cached=True): class FormMeta(Meta): def __init__(self, doctype): - super().__init__(doctype) + self.__dict__.update(frappe.get_meta(doctype).__dict__) self.load_assets() def load_assets(self): From 4c1b2ae67c72067857e876872652c58850f99bd9 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 25 Jan 2023 12:04:34 +0530 Subject: [PATCH 172/407] refactor: get_valid_dict * Util get_permitted_fields checks for valid columns instead of planned logic * Remove virtual field from dict if not in permitted fields * Remove reliance on sentinel object _DOC_DELETED_ATTR --- frappe/model/__init__.py | 22 ++++++++++++++++++++++ frappe/model/base_document.py | 14 +++++++------- frappe/model/db_query.py | 25 +------------------------ 3 files changed, 30 insertions(+), 31 deletions(-) diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index a7f966ebd6..9ac9f4396e 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -188,3 +188,25 @@ def delete_fields(args_dict, delete=0): if frappe.db.db_type == "postgres": # commit the results to db frappe.db.commit() + + +def get_permitted_fields( + doctype: str, parenttype: str | None = None, user: str | None = None +) -> list[str]: + meta = frappe.get_meta(doctype) + valid_columns = meta.get_valid_columns() + + if doctype in core_doctypes_list: + return valid_columns + + meta_fields = meta.default_fields.copy() + optional_meta_fields = [x for x in optional_fields if x in valid_columns] + + if meta.istable: + meta_fields.extend(child_table_fields) + + return ( + meta_fields + + meta.get_permitted_fieldnames(parenttype=parenttype, user=user) + + optional_meta_fields + ) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 6fb525692f..644868da92 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -11,6 +11,7 @@ from frappe.model import ( default_fields, display_fieldtypes, float_like_fields, + get_permitted_fields, table_fields, ) from frappe.model.docstatus import DocStatus @@ -32,7 +33,6 @@ DOCTYPE_TABLE_FIELDS = [ TABLE_DOCTYPES_FOR_DOCTYPE = {df["fieldname"]: df["options"] for df in DOCTYPE_TABLE_FIELDS} DOCTYPES_FOR_DOCTYPE = {"DocType", *TABLE_DOCTYPES_FOR_DOCTYPE.values()} -_DOC_DELETED_ATTR = object() def get_controller(doctype): @@ -298,8 +298,10 @@ class BaseDocument: self, sanitize=True, convert_dates_to_str=False, ignore_nulls=False, ignore_virtual=False ) -> dict: d = _dict() + permitted_fields = get_permitted_fields(doctype=self.doctype) + for fieldname in self.meta.get_valid_columns(): - field_value = getattr(self, fieldname, _DOC_DELETED_ATTR) + field_value = getattr(self, fieldname, None) # column is valid, we can use getattr d[fieldname] = field_value @@ -313,11 +315,11 @@ class BaseDocument: if df: if is_virtual_field: - if ignore_virtual: + if ignore_virtual or fieldname not in permitted_fields: del d[fieldname] continue - if d[fieldname] in {None, _DOC_DELETED_ATTR} and (options := getattr(df, "options", None)): + if d[fieldname] is None and (options := getattr(df, "options", None)): from frappe.utils.safe_exec import get_safe_globals d[fieldname] = frappe.safe_eval( @@ -351,9 +353,7 @@ class BaseDocument: ): d[fieldname] = str(d[fieldname]) - if ignore_nulls and d[fieldname] is None: - del d[fieldname] - elif not is_virtual_field and field_value is _DOC_DELETED_ATTR: + if ignore_nulls and not is_virtual_field and d[fieldname] is None: del d[fieldname] return d diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 7d797505f7..aea2991356 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -14,7 +14,7 @@ import frappe.share from frappe import _ from frappe.core.doctype.server_script.server_script_utils import get_server_script_map from frappe.database.utils import FallBackDateTimeStr, NestedSetHierarchy -from frappe.model import child_table_fields, core_doctypes_list, optional_fields +from frappe.model import get_permitted_fields, optional_fields from frappe.model.meta import get_table_columns from frappe.model.utils.user_settings import get_user_settings, update_user_settings from frappe.query_builder.utils import Column @@ -1227,29 +1227,6 @@ def requires_owner_constraint(role_permissions): return True -def get_permitted_fields(doctype, parenttype=None): - meta = frappe.get_meta(doctype) - - if doctype in core_doctypes_list: - return meta.get_valid_columns() - - meta_fields = meta.default_fields.copy() - optional_meta_fields = list(optional_fields) - - if not meta.track_seen: - optional_meta_fields.remove("_seen") - - if not meta.is_submittable: - meta_fields.remove("docstatus") - - if meta.istable: - meta_fields.extend(child_table_fields) - else: - meta_fields.remove("idx") - - return meta_fields + meta.get_permitted_fieldnames(parenttype=parenttype) + optional_meta_fields - - def wrap_grave_quotes(table: str) -> str: if table[0] != "`": table = f"`{table}`" From 4accf0ed9d508440c8dc5158b569bd0e0af908b6 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 25 Jan 2023 12:38:15 +0530 Subject: [PATCH 173/407] fix: Consider virtual fields in fields with values conditionally --- frappe/model/meta.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frappe/model/meta.py b/frappe/model/meta.py index cfe43d8728..0f785be0bf 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -495,9 +495,11 @@ class Meta(Document): if custom_perms: self.permissions = [Document(d) for d in custom_perms] - def get_fieldnames_with_value(self, with_field_meta=False): + def get_fieldnames_with_value(self, with_field_meta=False, with_virtual_fields=False): def is_value_field(docfield): - return not (docfield.get("is_virtual") or docfield.fieldtype in no_value_fields) + return not ( + not with_virtual_fields and docfield.get("is_virtual") or docfield.fieldtype in no_value_fields + ) if with_field_meta: return [df for df in self.fields if is_value_field(df)] @@ -536,7 +538,7 @@ class Meta(Document): self.permitted_fieldnames = [] permlevel_access = set(self.get_permlevel_access("read", parenttype, user=user)) - for df in self.get_fieldnames_with_value(with_field_meta=True): + for df in self.get_fieldnames_with_value(with_field_meta=True, with_virtual_fields=True): if df.permlevel in permlevel_access: self.permitted_fieldnames.append(df.fieldname) From 895f1d3f3adcdbc75b1aef4aea6c440961a22c32 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 25 Jan 2023 12:45:36 +0530 Subject: [PATCH 174/407] fix(db_query): Check if params in sql fn call are all permitted access --- frappe/model/db_query.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 505d0f4da1..e1311aa7ad 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -612,13 +612,15 @@ class DatabaseQuery: elif "(" in field: if "*" in field: continue - elif _params := FN_PARAMS_PATTERN.findall(column): - params = (x for x in _params[0].split(",")) + elif _params := FN_PARAMS_PATTERN.findall(field): + params = (x.strip() for x in _params[0].split(",")) for param in params: - if ( + if not ( not param or param in permitted_fields or param.isnumeric() or "'" in param or '"' in param ): - continue + self.fields.remove(field) + break + continue self.fields.remove(field) # remove if access not allowed From 31049b705eb61d33ccebb6368f071f480284dd79 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 25 Jan 2023 14:01:02 +0530 Subject: [PATCH 175/407] fix(db_query): With as_list, pass NULL to maintain order --- .../test_addresses_and_contacts.py | 3 --- frappe/model/db_query.py | 23 ++++++++++++++----- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py b/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py index d15d518f63..74c797ca65 100644 --- a/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py +++ b/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py @@ -112,6 +112,3 @@ class TestAddressesAndContacts(FrappeTestCase): 1, ] self.assertListEqual(test_item, report_data[idx]) - - def tearDown(self): - frappe.db.rollback() diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index e1311aa7ad..b3d19b01a4 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -269,6 +269,10 @@ class DatabaseQuery: # Wrapping fields with grave quotes to allow support for sql keywords # TODO: Add support for wrapping fields with sql functions and distinct keyword for field in self.fields: + if field is None: + fields.append("NULL") + continue + stripped_field = field.strip().lower() if ( @@ -487,12 +491,13 @@ class DatabaseQuery: if len(self.tables) > 1 or len(self.link_tables) > 0: for idx, field in enumerate(self.fields): - if "." not in field and not _in_standard_sql_methods(field): + if field is not None and "." not in field and not _in_standard_sql_methods(field): self.fields[idx] = f"{self.tables[0]}.{field}" def cast_name_fields(self): for i, field in enumerate(self.fields): - self.fields[i] = cast_name(field) + if field is not None: + self.fields[i] = cast_name(field) def get_table_columns(self): try: @@ -557,6 +562,12 @@ class DatabaseQuery: else: conditions.append(self.prepare_filter_condition(f)) + def remove_field(self, idx: int): + if self.as_list: + self.fields[idx] = None + else: + self.fields.pop(idx) + def apply_fieldlevel_read_permissions(self): """Apply fieldlevel read permissions to the query""" if self.flags.ignore_permissions: @@ -601,7 +612,7 @@ class DatabaseQuery: if column in permitted_child_table_fields: continue else: - self.fields.remove(field) + self.remove_field(i) else: raise frappe.PermissionError(ch_doctype) @@ -618,14 +629,14 @@ class DatabaseQuery: if not ( not param or param in permitted_fields or param.isnumeric() or "'" in param or '"' in param ): - self.fields.remove(field) + self.remove_field(i) break continue - self.fields.remove(field) + self.remove_field(i) # remove if access not allowed else: - self.fields.remove(field) + self.remove_field(i) # handle * fields j = 0 From cf92c5ac849871d9d6503afa93c7f043f17c0a11 Mon Sep 17 00:00:00 2001 From: Sabu Siyad Date: Wed, 25 Jan 2023 16:05:36 +0530 Subject: [PATCH 176/407] fix(translation): empty string passed gets passed (#19776) It is reserved by GNU gettext: gettext("") returns the header entry with meta information, not the empty string --- frappe/custom/doctype/custom_field/custom_field.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index f14a4588a8..758d9c1e64 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -142,7 +142,7 @@ def get_fields_label(doctype=None): return frappe.msgprint(_("Custom Fields can only be added to a standard DocType.")) return [ - {"value": df.fieldname or "", "label": _(df.label or "")} + {"value": df.fieldname or "", "label": _(df.label) if df.label else ""} for df in frappe.get_meta(doctype).get("fields") ] From 599f5a0ce5ae2eda69bfeb2d8f2470c6f66a0c76 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 25 Jan 2023 17:25:26 +0530 Subject: [PATCH 177/407] build(deps): Bump frappe-datatable to 1.17.0 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 0028ad3e9e..514c119157 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "fast-deep-equal": "^2.0.1", "fast-glob": "^3.2.5", "frappe-charts": "2.0.0-rc22", - "frappe-datatable": "^1.16.4", + "frappe-datatable": "^1.17.0", "frappe-gantt": "^0.6.0", "highlight.js": "^10.4.1", "html5-qrcode": "^2.0.11", diff --git a/yarn.lock b/yarn.lock index d2ee8e62a5..6893ef3e07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1435,10 +1435,10 @@ frappe-charts@2.0.0-rc22: resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-2.0.0-rc22.tgz#9a5a747febdc381a1d4d7af96e89cf519dfba8c0" integrity sha512-N7f/8979wJCKjusOinaUYfMxB80YnfuVLrSkjpj4LtyqS0BGS6SuJxUnb7Jl4RWUFEIs7zEhideIKnyLeFZF4Q== -frappe-datatable@^1.16.4: - version "1.16.4" - resolved "https://registry.yarnpkg.com/frappe-datatable/-/frappe-datatable-1.16.4.tgz#cb26f197c3cd404a5b13f016ef81c394e06f56fe" - integrity sha512-VoiTLnkuObMa3FxITrvP32UYN9v4WQ0j4qlCiDuqdXha9/BVSxwDt2BTK+cvaRloGcds5G2Hm9IRbltRRGGhxA== +frappe-datatable@^1.17.0: + version "1.17.0" + resolved "https://registry.yarnpkg.com/frappe-datatable/-/frappe-datatable-1.17.0.tgz#bf13553320408acdc3b491049e05c040ea74cab8" + integrity sha512-QstM7Qg9ZLOxBidU1LBzKEjzAIQBmRmtLakRfqEsrlH4snbeKbcNFIAhiHSOe28d49gEsE3p0kLW3KU0ByID4g== dependencies: hyperlist "^1.0.0-beta" lodash "^4.17.5" From 2a1c5f1fa92fc0f131623d01248dad85a6c80140 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Wed, 25 Jan 2023 18:45:10 +0530 Subject: [PATCH 178/407] build(deps): Bump frappe-datatable to 1.17.1 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 514c119157..589d2dd6c9 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "fast-deep-equal": "^2.0.1", "fast-glob": "^3.2.5", "frappe-charts": "2.0.0-rc22", - "frappe-datatable": "^1.17.0", + "frappe-datatable": "^1.17.1", "frappe-gantt": "^0.6.0", "highlight.js": "^10.4.1", "html5-qrcode": "^2.0.11", diff --git a/yarn.lock b/yarn.lock index 6893ef3e07..e5621d385f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1435,10 +1435,10 @@ frappe-charts@2.0.0-rc22: resolved "https://registry.yarnpkg.com/frappe-charts/-/frappe-charts-2.0.0-rc22.tgz#9a5a747febdc381a1d4d7af96e89cf519dfba8c0" integrity sha512-N7f/8979wJCKjusOinaUYfMxB80YnfuVLrSkjpj4LtyqS0BGS6SuJxUnb7Jl4RWUFEIs7zEhideIKnyLeFZF4Q== -frappe-datatable@^1.17.0: - version "1.17.0" - resolved "https://registry.yarnpkg.com/frappe-datatable/-/frappe-datatable-1.17.0.tgz#bf13553320408acdc3b491049e05c040ea74cab8" - integrity sha512-QstM7Qg9ZLOxBidU1LBzKEjzAIQBmRmtLakRfqEsrlH4snbeKbcNFIAhiHSOe28d49gEsE3p0kLW3KU0ByID4g== +frappe-datatable@^1.17.1: + version "1.17.1" + resolved "https://registry.yarnpkg.com/frappe-datatable/-/frappe-datatable-1.17.1.tgz#795ee79a420df07b963b7decf489045d5993cc0b" + integrity sha512-qqvmsaYbQUwCAtGnhmTN8jrdvXW6YfRLTZS6ufb3b1ibFEMUbE04rEFJF7TJRd2ugSk80seS2OPGTZGw+V2b0A== dependencies: hyperlist "^1.0.0-beta" lodash "^4.17.5" From ffc5447548716442d6682a91b66879d7e63b2efe Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Jan 2023 20:33:55 +0530 Subject: [PATCH 179/407] fix: set-config without `-g` must specify site (#19782) --- frappe/commands/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 280e656f1c..2a1f7d3e5e 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -1071,6 +1071,8 @@ def set_config(context, key, value, global_=False, parse=False, as_dict=False): common_site_config_path = os.path.join(sites_path, "common_site_config.json") update_site_config(key, value, validate=False, site_config_path=common_site_config_path) else: + if not context.sites: + raise SiteNotSpecifiedError for site in context.sites: frappe.init(site=site) update_site_config(key, value, validate=False) From 9bccedc761413e8e967582296da4862a4012c715 Mon Sep 17 00:00:00 2001 From: V Shankar <95605398+Shankarv19bcr@users.noreply.github.com> Date: Thu, 26 Jan 2023 15:30:47 +0530 Subject: [PATCH 180/407] feat: change quick entry dialog size based on column breaks (#19715) Co-authored-by: Shariq Ansari <30859809+shariquerik@users.noreply.github.com> --- frappe/public/js/frappe/ui/dialog.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/frappe/public/js/frappe/ui/dialog.js b/frappe/public/js/frappe/ui/dialog.js index ed42b81b68..9ec6922306 100644 --- a/frappe/public/js/frappe/ui/dialog.js +++ b/frappe/public/js/frappe/ui/dialog.js @@ -28,6 +28,8 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup { this.get_close_btn().hide(); } + if (!this.size) this.set_modal_size(); + this.wrapper = this.$wrapper.find(".modal-dialog").get(0); if (this.size == "small") $(this.wrapper).addClass("modal-sm"); else if (this.size == "large") $(this.wrapper).addClass("modal-lg"); @@ -123,6 +125,31 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup { }); } + set_modal_size() { + if (!this.fields) { + this.size = ""; + return; + } + + let col_brk = 0; + let cur_col_brk = 0; + + // if fields have more than 2 Column Breaks before encountering Section Break, make it large + this.fields.forEach((field) => { + if (field.fieldtype == "Column Break") { + cur_col_brk++; + + if (cur_col_brk > col_brk) { + col_brk = cur_col_brk; + } + } else if (field.fieldtype == "Section Break") { + cur_col_brk = 0; + } + }); + + this.size = col_brk >= 4 ? "extra-large" : col_brk >= 2 ? "large" : ""; + } + get_primary_btn() { return this.standard_actions.find(".btn-primary"); } From 586613d94caa6c61828887136495392504d08069 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 26 Jan 2023 15:36:18 +0530 Subject: [PATCH 181/407] test: fixed failing control_color UI test --- cypress/integration/control_color.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/integration/control_color.js b/cypress/integration/control_color.js index aa3a45eed8..e97dbe0f06 100644 --- a/cypress/integration/control_color.js +++ b/cypress/integration/control_color.js @@ -26,7 +26,7 @@ context("Control Color", () => { //Checking if the css attribute is correct cy.get(".color-map").should("have.css", "color", "rgb(79, 157, 217)"); - cy.get(".hue-map").should("have.css", "color", "rgb(0, 145, 255)"); + cy.get(".hue-map").should("have.css", "color", "rgb(0, 144, 255)"); //Checking if the correct color is being selected cy.get("@dialog").then((dialog) => { From ed86da416f71d41c048a198535fc78632bc9565a Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 26 Jan 2023 15:50:12 +0530 Subject: [PATCH 182/407] test: fixed failing web_form UI test --- cypress/support/commands.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 0a25ff5cab..c067974d9f 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -285,7 +285,7 @@ Cypress.Commands.add("get_open_dialog", () => { Cypress.Commands.add("save", () => { cy.intercept("/api/method/frappe.desk.form.save.savedocs").as("save_call"); - cy.get(`button[data-label="Save"]:visible`).click({ scrollBehavior: "top", force: true }); + cy.get(`.page-container:visible button[data-label="Save"]`).click({ force: true }); cy.wait("@save_call"); }); Cypress.Commands.add("hide_dialog", () => { From 4bcb12617c6d14cd323d1fad7da295d8679eb2f3 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Thu, 26 Jan 2023 11:39:41 +0100 Subject: [PATCH 183/407] fix: assertAlmostEqual with precision --- frappe/model/base_document.py | 2 +- frappe/tests/utils.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 8cd074a4c0..783e879bff 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -1068,7 +1068,7 @@ class BaseDocument: def is_dummy_password(self, pwd): return "".join(set(pwd)) == "*" - def precision(self, fieldname, parentfield=None): + def precision(self, fieldname, parentfield=None) -> int | None: """Returns float precision for a particular field (or get global default). :param fieldname: Fieldname for which precision is required. diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py index 15e0c3d9c0..5f13c9cd11 100644 --- a/frappe/tests/utils.py +++ b/frappe/tests/utils.py @@ -55,12 +55,14 @@ class FrappeTestCase(unittest.TestCase): else: self._compare_field(value, actual.get(field), actual, field) - def _compare_field(self, expected, actual, doc, field): + def _compare_field(self, expected, actual, doc: BaseDocument, field: str): msg = f"{field} should be same." if isinstance(expected, float): precision = doc.precision(field) - self.assertAlmostEqual(expected, actual, f"{field} should be same to {precision} digits") + self.assertAlmostEqual( + expected, actual, places=precision, msg=f"{field} should be same to {precision} digits" + ) elif isinstance(expected, (bool, int)): self.assertEqual(expected, cint(actual), msg=msg) elif isinstance(expected, datetime_like_types): From ea306db2a2a5c6c20a53a725267df187411913e4 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 26 Jan 2023 16:11:49 +0530 Subject: [PATCH 184/407] test: fix flaky view_routing UI test --- cypress/integration/view_routing.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cypress/integration/view_routing.js b/cypress/integration/view_routing.js index 1e3b841c79..9267974154 100644 --- a/cypress/integration/view_routing.js +++ b/cypress/integration/view_routing.js @@ -103,8 +103,9 @@ context("View", () => { }); it("Route to File View", () => { + cy.intercept("POST", "/api/method/frappe.desk.reportview.get").as("list_loaded"); cy.visit("app/file"); - cy.wait(500); + cy.wait("@list_loaded"); cy.window() .its("cur_list") .then((list) => { @@ -113,7 +114,7 @@ context("View", () => { }); cy.visit("app/file/view/home/Attachments"); - cy.wait(500); + cy.wait("@list_loaded"); cy.window() .its("cur_list") .then((list) => { From 28e9b44dbc804560fae99e2afa56067b88ac561a Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 26 Jan 2023 16:19:23 +0530 Subject: [PATCH 185/407] test: fix flaky folder_navigation UI test --- cypress/integration/folder_navigation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/integration/folder_navigation.js b/cypress/integration/folder_navigation.js index 60fa46bc88..c5b3a44f0d 100644 --- a/cypress/integration/folder_navigation.js +++ b/cypress/integration/folder_navigation.js @@ -10,7 +10,7 @@ context("Folder Navigation", () => { cy.get(".filter-selector > .btn").findByText("1 filter").click(); cy.findByRole("button", { name: "Clear Filters" }).click(); cy.get(".filter-action-buttons > .text-muted").findByText("+ Add a Filter").click(); - cy.get(".fieldname-select-area > .awesomplete > .form-control").type("Fol{enter}"); + cy.get(".fieldname-select-area > .awesomplete > .form-control:last").type("Fol{enter}"); cy.get( ".filter-field > .form-group > .link-field > .awesomplete > .input-with-feedback" ).type("Home{enter}"); From 114db456f84549d7462a83c078bc3a468e3ce901 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 26 Jan 2023 16:22:57 +0530 Subject: [PATCH 186/407] test: fix flaky navigation UI test --- cypress/integration/navigation.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cypress/integration/navigation.js b/cypress/integration/navigation.js index 2302296f23..cf1b5dc89d 100644 --- a/cypress/integration/navigation.js +++ b/cypress/integration/navigation.js @@ -18,6 +18,7 @@ context("Navigation", () => { it.only("Navigate to previous page after login", () => { cy.visit("/app/todo"); cy.get(".page-head").findByTitle("To Do").should("be.visible"); + cy.clear_filters(); cy.request("/api/method/logout"); cy.reload().as("reload"); cy.get("@reload").get(".page-card .btn-primary").contains("Login").click(); From 177ca618eb06e6d93d22e6592d5a758da6e84947 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 26 Jan 2023 16:45:25 +0530 Subject: [PATCH 187/407] test: fix flaky control_link UI test --- cypress/integration/control_link.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/cypress/integration/control_link.js b/cypress/integration/control_link.js index a5281d9b09..d3462492f6 100644 --- a/cypress/integration/control_link.js +++ b/cypress/integration/control_link.js @@ -229,19 +229,15 @@ context("Control Link", () => { ); cy.reload(); cy.new_form("ToDo"); - 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.fill_field("description", "new", "Text Editor").wait(200); + cy.save(); 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.save(); cy.get_field("assigned_by").should("have.value", ""); cy.get(".frappe-control[data-fieldname=assigned_by_full_name] .control-value").should( "contain", From 8fe6b8f3d99bc821325c68692abe9dd7e60b14a8 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Thu, 26 Jan 2023 13:53:03 +0100 Subject: [PATCH 188/407] fix: add freeze message for bulk delete --- frappe/public/js/frappe/list/bulk_operations.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/public/js/frappe/list/bulk_operations.js b/frappe/public/js/frappe/list/bulk_operations.js index c9e8f7d329..7f317137b9 100644 --- a/frappe/public/js/frappe/list/bulk_operations.js +++ b/frappe/public/js/frappe/list/bulk_operations.js @@ -146,6 +146,8 @@ export default class BulkOperations { .call({ method: "frappe.desk.reportview.delete_items", freeze: true, + freeze_message: + docnames.length <= 10 ? __("Deleting {0}...", [docnames.join(", ")]) : null, args: { items: docnames, doctype: this.doctype, From 9d9c06985ed3ac671c3bb96ccf597c7d068c59f5 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Thu, 26 Jan 2023 14:49:50 +0100 Subject: [PATCH 189/407] fix: use count instead of concatenated docnames --- frappe/public/js/frappe/list/bulk_operations.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/list/bulk_operations.js b/frappe/public/js/frappe/list/bulk_operations.js index 7f317137b9..e7e3fb256f 100644 --- a/frappe/public/js/frappe/list/bulk_operations.js +++ b/frappe/public/js/frappe/list/bulk_operations.js @@ -147,7 +147,9 @@ export default class BulkOperations { method: "frappe.desk.reportview.delete_items", freeze: true, freeze_message: - docnames.length <= 10 ? __("Deleting {0}...", [docnames.join(", ")]) : null, + docnames.length <= 10 + ? __("Deleting {0} records...", [docnames.length]) + : null, args: { items: docnames, doctype: this.doctype, From e93345d20e1ead3f9ce5d46184c4b21f4bd4d55b Mon Sep 17 00:00:00 2001 From: Devin Slauenwhite Date: Thu, 26 Jan 2023 16:20:43 -0500 Subject: [PATCH 190/407] fix: grid search without frm --- frappe/public/js/frappe/form/grid.js | 4 +--- frappe/public/js/frappe/form/grid_row.js | 12 ++++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index e80a07f8ac..9e7a8b58aa 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -580,9 +580,7 @@ export default class Grid { } get_filtered_data() { - if (!this.frm) return; - - let all_data = this.frm.doc[this.df.fieldname]; + let all_data = this.frm ? this.frm.doc[this.df.fieldname] : this.df.data; for (const field in this.filter) { all_data = all_data.filter((data) => { diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index 0b6ac5c208..b8bb8756d3 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -757,12 +757,12 @@ export default class GridRow { show_search_row() { // show or remove search columns based on grid rows - this.show_search = - this.frm && - this.frm.doc && - this.frm.doc[this.grid.df.fieldname] && - this.frm.doc[this.grid.df.fieldname].length >= 20; - !this.show_search && this.wrapper.remove(); + // this.show_search = + // this.frm && + // this.frm.doc && + // this.frm.doc[this.grid.df.fieldname] && + // this.frm.doc[this.grid.df.fieldname].length >= 20; + // !this.show_search && this.wrapper.remove(); return this.show_search; } From 67c5a8d75a16920cd574a6e70aff05dc8080c41f Mon Sep 17 00:00:00 2001 From: Devin Slauenwhite Date: Thu, 26 Jan 2023 16:31:07 -0500 Subject: [PATCH 191/407] fix: remove incompatible logic --- frappe/public/js/frappe/form/grid_row.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index b8bb8756d3..b39b0b1641 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -756,13 +756,6 @@ export default class GridRow { } show_search_row() { - // show or remove search columns based on grid rows - // this.show_search = - // this.frm && - // this.frm.doc && - // this.frm.doc[this.grid.df.fieldname] && - // this.frm.doc[this.grid.df.fieldname].length >= 20; - // !this.show_search && this.wrapper.remove(); return this.show_search; } From 10104abeb2b5e3b83afb3d0d6f49a1dc9be10218 Mon Sep 17 00:00:00 2001 From: Devin Slauenwhite Date: Thu, 26 Jan 2023 17:15:22 -0500 Subject: [PATCH 192/407] fix: limit search rows to >= 20 --- frappe/public/js/frappe/form/grid_row.js | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index b39b0b1641..35caaa667f 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -756,6 +756,7 @@ export default class GridRow { } show_search_row() { + this.show_search = this.show_search && this.grid.data.length >= 20; return this.show_search; } From c0fab395a74ce283d4597f50d2adae15953d0f1e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 27 Jan 2023 09:04:35 +0530 Subject: [PATCH 193/407] ci(vuln check): skip dropbox package and use cache --- .github/workflows/linters.yml | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 01b5407489..eb775e01cd 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -80,7 +80,20 @@ jobs: - uses: actions/setup-python@v4 with: python-version: '3.10' + - uses: actions/checkout@v3 + + - name: Cache pip + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }} + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + - run: | pip install pip-audit - pip-audit ${GITHUB_WORKSPACE} + cd ${GITHUB_WORKSPACE} + sed -i '/dropbox/d' pyproject.toml # Remove dropbox temporarily https://github.com/dropbox/dropbox-sdk-python/pull/456 + pip-audit . From db9b25ec0e529ffb654faa97ff833b64442f78c5 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Fri, 27 Jan 2023 03:57:11 +0000 Subject: [PATCH 194/407] fix(MariaDBTable): dont attempt to drop index twice (#19783) --- frappe/database/mariadb/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/database/mariadb/schema.py b/frappe/database/mariadb/schema.py index 13525d2328..bbdd95d921 100644 --- a/frappe/database/mariadb/schema.py +++ b/frappe/database/mariadb/schema.py @@ -94,7 +94,7 @@ class MariaDBTable(DBTable): if not frappe.db.get_column_index(self.table_name, col.fieldname, unique=False): add_index_query.append(f"ADD INDEX `{col.fieldname}_index`(`{col.fieldname}`)") - for col in self.drop_index + self.drop_unique: + for col in {*self.drop_index, *self.drop_unique}: if col.fieldname == "name": continue From 70ee9272b100743ad2b75870a7b6f611483cddd4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 27 Jan 2023 12:44:33 +0530 Subject: [PATCH 195/407] fix: sanitize traceback for common secrets (#19805) --- frappe/tests/test_utils.py | 13 ++++++++++++ frappe/utils/__init__.py | 43 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index c6f7b8302f..59df08dd91 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -960,3 +960,16 @@ class TestTypingValidations(FrappeTestCase): report.toggle_disable(changed_value) report.toggle_disable(current_value) + + +class TestTBSanitization(FrappeTestCase): + def test_traceback_sanitzation(self): + try: + password = "42" + args = {"password": "42", "pwd": "42", "safe": "safe_value"} + raise Exception + except Exception: + traceback = frappe.get_traceback(with_context=True) + self.assertNotIn("42", traceback) + self.assertIn("********", traceback) + self.assertIn("safe_value", traceback) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index c715097be2..d82c039b95 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -24,7 +24,6 @@ from typing import Any, Literal from urllib.parse import quote, urlparse from redis.exceptions import ConnectionError -from traceback_with_variables import iter_exc_lines from werkzeug.test import Client import frappe @@ -298,13 +297,15 @@ def get_traceback(with_context=False) -> str: """ Returns the traceback of the Exception """ + from traceback_with_variables import iter_exc_lines + exc_type, exc_value, exc_tb = sys.exc_info() if not any([exc_type, exc_value, exc_tb]): return "" if with_context: - trace_list = iter_exc_lines() + trace_list = iter_exc_lines(fmt=_get_sanitizer()) tb = "\n".join(trace_list) else: trace_list = traceback.format_exception(exc_type, exc_value, exc_tb) @@ -314,6 +315,44 @@ def get_traceback(with_context=False) -> str: return tb.replace(bench_path, "") +@functools.lru_cache(maxsize=1) +def _get_sanitizer(): + from traceback_with_variables import Format + + blocklist = [ + "password", + "passwd", + "secret", + "token", + "key", + "pwd", + ] + + placeholder = "********" + + def dict_printer(v: dict) -> str: + from copy import deepcopy + + v = deepcopy(v) + for key in blocklist: + if key in v: + v[key] = placeholder + + return str(v) + + # Adapted from https://github.com/andy-landy/traceback_with_variables/blob/master/examples/format_customized.py + # Reused under MIT license: https://github.com/andy-landy/traceback_with_variables/blob/master/LICENSE + + return Format( + custom_var_printers=[ + # redact variables + *[(variable_name, lambda: placeholder) for variable_name in blocklist], + # redact dictionary keys + (["_secret", dict, lambda *a, **kw: False], dict_printer), + ], + ) + + def log(event, details): frappe.logger(event).info(details) From bc9ed4a422a8ab1c1617a45137f95dc822f59483 Mon Sep 17 00:00:00 2001 From: Ritwik Puri Date: Fri, 27 Jan 2023 14:19:05 +0530 Subject: [PATCH 196/407] chore: remove prepared report event from system settings controller (#19808) [skip ci] --- frappe/core/doctype/system_settings/system_settings.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/frappe/core/doctype/system_settings/system_settings.js b/frappe/core/doctype/system_settings/system_settings.js index 3db3eef299..1de4dd82e7 100644 --- a/frappe/core/doctype/system_settings/system_settings.js +++ b/frappe/core/doctype/system_settings/system_settings.js @@ -30,13 +30,6 @@ frappe.ui.form.on("System Settings", { frm.set_value("bypass_restrict_ip_check_if_2fa_enabled", 0); } }, - enable_prepared_report_auto_deletion: function (frm) { - if (frm.doc.enable_prepared_report_auto_deletion) { - if (!frm.doc.prepared_report_expiry_period) { - frm.set_value("prepared_report_expiry_period", 7); - } - } - }, on_update: function (frm) { if (frappe.boot.time_zone && frappe.boot.time_zone.system !== frm.doc.time_zone) { // Clear cache after saving to refresh the values of boot. From b8deb7241104db1e702d35606b715e3481b2cf58 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 27 Jan 2023 14:26:54 +0530 Subject: [PATCH 197/407] fix: prepared report patch (#19807) - the patch is failing if system setting is updated and column is gone - the message is misleading "invalid column", it should be "missing field". --- frappe/database/database.py | 4 +++- frappe/patches.txt | 2 +- ...emove_prepared_report_settings_from_system_settings.py | 8 +------- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index fe258be8d7..cf4ceef461 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -772,7 +772,9 @@ class Database: if not df: frappe.throw( - _("Invalid field name: {0}").format(frappe.bold(fieldname)), self.InvalidColumnName + _("Field {0} does not exist on {1}").format( + frappe.bold(fieldname), frappe.bold(doctype), self.InvalidColumnName + ) ) val = cast_fieldtype(df.fieldtype, val) diff --git a/frappe/patches.txt b/frappe/patches.txt index 7f7ab9bfe2..49b43f0558 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -196,7 +196,6 @@ frappe.patches.v14_0.setup_likes_from_feedback frappe.patches.v14_0.update_webforms frappe.patches.v14_0.delete_payment_gateways frappe.patches.v15_0.remove_event_streaming -frappe.patches.v15_0.remove_prepared_report_settings_from_system_settings frappe.patches.v15_0.copy_disable_prepared_report_to_prepared_report [post_model_sync] @@ -221,3 +220,4 @@ frappe.patches.v14_0.update_attachment_comment frappe.patches.v15_0.set_contact_full_name execute:frappe.delete_doc("Page", "activity", force=1) frappe.patches.v14_0.disable_email_accounts_with_oauth +frappe.patches.v15_0.remove_prepared_report_settings_from_system_settings diff --git a/frappe/patches/v15_0/remove_prepared_report_settings_from_system_settings.py b/frappe/patches/v15_0/remove_prepared_report_settings_from_system_settings.py index 8c0ec4ca70..2c203784df 100644 --- a/frappe/patches/v15_0/remove_prepared_report_settings_from_system_settings.py +++ b/frappe/patches/v15_0/remove_prepared_report_settings_from_system_settings.py @@ -4,12 +4,6 @@ from frappe.utils import cint def execute(): expiry_period = ( - cint(frappe.db.get_single_value("System Settings", "prepared_report_expiry_period")) or 30 + cint(frappe.db.get_singles_dict("System Settings").get("prepared_report_expiry_period")) or 30 ) frappe.get_single("Log Settings").register_doctype("Prepared Report", expiry_period) - - singles = frappe.qb.DocType("Singles") - frappe.qb.from_(singles).delete().where( - (singles.doctype == "System Settings") - & (singles.field.isin(["enable_prepared_report_auto_deletion", "prepared_report_expiry_period"])) - ).run() From b812f73339dff2c6373d7f49d1bec4499d5bd69f Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Fri, 27 Jan 2023 15:57:02 +0100 Subject: [PATCH 198/407] feat: better freeze message --- frappe/public/scss/desk/global.scss | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/frappe/public/scss/desk/global.scss b/frappe/public/scss/desk/global.scss index 0d7ca9ac06..5294779990 100644 --- a/frappe/public/scss/desk/global.scss +++ b/frappe/public/scss/desk/global.scss @@ -424,26 +424,19 @@ kbd { background-color: var(--bg-color); .freeze-message-container { + inset: 0; + padding: 3rem; + background-color: var(--bg-light-gray); + color: var(--text-on-light-gray); + font-size: larger; position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; display: grid; place-content: center; } - - .freeze-message { - color: var(--text-color) !important; - } -} - -#freeze.dark { - background-color: var(--gray-900); } #freeze.in { - opacity: 0.5; + opacity: 0.8; } .msg-box { From ee80d6a504a736380f6f9b1e5bf1da954cc3609c Mon Sep 17 00:00:00 2001 From: gavin Date: Fri, 27 Jan 2023 22:57:19 +0530 Subject: [PATCH 199/407] feat(cli): Pass extra args to DB console (#19809) --- frappe/commands/utils.py | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 2a1f7d3e5e..5ec0b54828 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -17,6 +17,7 @@ DATA_IMPORT_DEPRECATION = ( "[DEPRECATED] The `import-csv` command used 'Data Import Legacy' which has been deprecated.\n" "Use `data-import` command instead to import data via 'Data Import'." ) +EXTRA_ARGS_CTX = {"ignore_unknown_options": True, "allow_extra_args": True} @click.command("build") @@ -485,9 +486,10 @@ def bulk_rename(context, doctype, path): frappe.destroy() -@click.command("db-console") +@click.command("db-console", context_settings=EXTRA_ARGS_CTX) +@click.argument("extra_args", nargs=-1) @pass_context -def database(context): +def database(context, extra_args): """ Enter into the Database console for given site. """ @@ -496,14 +498,18 @@ def database(context): raise SiteNotSpecifiedError frappe.init(site=site) if not frappe.conf.db_type or frappe.conf.db_type == "mariadb": - _mariadb() + _mariadb(extra_args=extra_args) elif frappe.conf.db_type == "postgres": - _psql() + _psql(extra_args=extra_args) -@click.command("mariadb") +@click.command( + "mariadb", + context_settings=EXTRA_ARGS_CTX, +) +@click.argument("extra_args", nargs=-1) @pass_context -def mariadb(context): +def mariadb(context, extra_args): """ Enter into mariadb console for a given site. """ @@ -511,21 +517,22 @@ def mariadb(context): if not site: raise SiteNotSpecifiedError frappe.init(site=site) - _mariadb() + _mariadb(extra_args=extra_args) -@click.command("postgres") +@click.command("postgres", context_settings=EXTRA_ARGS_CTX) +@click.argument("extra_args", nargs=-1) @pass_context -def postgres(context): +def postgres(context, extra_args): """ Enter into postgres console for a given site. """ site = get_site(context) frappe.init(site=site) - _psql() + _psql(extra_args=extra_args) -def _mariadb(): +def _mariadb(extra_args=None): from frappe.database.mariadb.database import MariaDBDatabase mysql = which("mysql") @@ -543,10 +550,12 @@ def _mariadb(): "--safe-updates", "-A", ] + if extra_args: + command += list(extra_args) os.execv(mysql, command) -def _psql(): +def _psql(extra_args=None): psql = which("psql") host = frappe.conf.db_host or "127.0.0.1" @@ -554,7 +563,10 @@ def _psql(): env = os.environ.copy() env["PGPASSWORD"] = frappe.conf.db_password conn_string = f"postgresql://{frappe.conf.db_name}@{host}:{port}/{frappe.conf.db_name}" - subprocess.run([psql, conn_string], check=True, env=env) + psql_cmd = [psql, conn_string] + if extra_args: + psql_cmd = psql_cmd + list(extra_args) + subprocess.run(psql_cmd, check=True, env=env) @click.command("jupyter") From d3d76865f72c52e71661ef87dde5121a77d59e2c Mon Sep 17 00:00:00 2001 From: morehardik <93863349+morehardik@users.noreply.github.com> Date: Sun, 29 Jan 2023 03:01:29 +0530 Subject: [PATCH 200/407] fix: check permission before running onload hook #11774 (#19823) Added fix for issue wherein Document is loaded post permission check --- frappe/desk/form/load.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index d7dfbb90d7..81f5096f29 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -28,14 +28,10 @@ def getdoc(doctype, name, user=None): if not (doctype and name): raise Exception("doctype and name required!") - if not name: - name = doctype - if not is_virtual_doctype(doctype) and not frappe.db.exists(doctype, name): return [] doc = frappe.get_doc(doctype, name) - run_onload(doc) if not doc.has_permission("read"): frappe.flags.error_message = _("Insufficient Permission for {0}").format( @@ -43,6 +39,8 @@ def getdoc(doctype, name, user=None): ) raise frappe.PermissionError(("read", doctype, name)) + + run_onload(doc) doc.apply_fieldlevel_read_permissions() # add file list From 4d08a989f54a158a0765bcd7c7b134c6846b0f29 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 25 Jan 2023 15:03:59 +0530 Subject: [PATCH 201/407] feat: Audit hooks report --- frappe/custom/report/__init__.py | 0 .../report/audit_system_hooks/__init__.py | 0 .../audit_system_hooks/audit_system_hooks.js | 7 +++ .../audit_system_hooks.json | 27 +++++++++ .../audit_system_hooks/audit_system_hooks.py | 60 +++++++++++++++++++ .../test_audit_system_hooks.py | 17 ++++++ 6 files changed, 111 insertions(+) create mode 100644 frappe/custom/report/__init__.py create mode 100644 frappe/custom/report/audit_system_hooks/__init__.py create mode 100644 frappe/custom/report/audit_system_hooks/audit_system_hooks.js create mode 100644 frappe/custom/report/audit_system_hooks/audit_system_hooks.json create mode 100644 frappe/custom/report/audit_system_hooks/audit_system_hooks.py create mode 100644 frappe/custom/report/audit_system_hooks/test_audit_system_hooks.py diff --git a/frappe/custom/report/__init__.py b/frappe/custom/report/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/custom/report/audit_system_hooks/__init__.py b/frappe/custom/report/audit_system_hooks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/custom/report/audit_system_hooks/audit_system_hooks.js b/frappe/custom/report/audit_system_hooks/audit_system_hooks.js new file mode 100644 index 0000000000..a78464f3da --- /dev/null +++ b/frappe/custom/report/audit_system_hooks/audit_system_hooks.js @@ -0,0 +1,7 @@ +// Copyright (c) 2023, Frappe Technologies and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Audit System Hooks"] = { + filters: [], +}; diff --git a/frappe/custom/report/audit_system_hooks/audit_system_hooks.json b/frappe/custom/report/audit_system_hooks/audit_system_hooks.json new file mode 100644 index 0000000000..d9ea86f07f --- /dev/null +++ b/frappe/custom/report/audit_system_hooks/audit_system_hooks.json @@ -0,0 +1,27 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2023-01-25 15:02:21.896117", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letter_head": "", + "modified": "2023-01-25 15:03:31.263337", + "modified_by": "Administrator", + "module": "Custom", + "name": "Audit System Hooks", + "owner": "Administrator", + "prepared_report": 0, + "query": "", + "ref_doctype": "System Settings", + "report_name": "Audit System Hooks", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + } + ] +} \ No newline at end of file diff --git a/frappe/custom/report/audit_system_hooks/audit_system_hooks.py b/frappe/custom/report/audit_system_hooks/audit_system_hooks.py new file mode 100644 index 0000000000..8dab192a14 --- /dev/null +++ b/frappe/custom/report/audit_system_hooks/audit_system_hooks.py @@ -0,0 +1,60 @@ +# Copyright (c) 2023, Frappe Technologies and contributors +# For license information, please see license.txt + +import frappe + + +def execute(filters=None): + frappe.only_for("System Manager") + + return get_columns(), get_data() + + +def get_columns(): + values_field_type = "Data" # TODO: better text wrapping in reportview + columns = [ + {"label": "Hook name", "fieldname": "hook_name", "fieldtype": "Data", "width": 200}, + {"label": "Hook key (optional)", "fieldname": "key", "fieldtype": "Data", "width": 200}, + {"label": "Hook Values", "fieldname": "hook_values", "fieldtype": values_field_type}, + ] + + # Each app is shown in order as a column + installed_apps = frappe.get_installed_apps(_ensure_on_bench=True) + columns += [ + {"label": app, "fieldname": app, "fieldtype": values_field_type} for app in installed_apps + ] + + return columns + + +def get_data(): + hooks = frappe.get_hooks() + installed_apps = frappe.get_installed_apps(_ensure_on_bench=True) + + def fmt_hook_values(v): + """Improve readability by discarding falsy values and removing containers when only 1 + value is in container""" + if not v: + return "" + + if isinstance(v, list) and len(v) == 1: + return str(v[0]) + + if isinstance(v, (dict, list)): + try: + return frappe.as_json(v) + except Exception: + pass + + return str(v) + + data = [] + for hook, values in hooks.items(): + row = {"hook_name": hook, "hook_values": fmt_hook_values(values)} + + for app in installed_apps: + row[app] = fmt_hook_values(frappe.get_hooks(hook, app_name=app)) + + data.append(row) + + return data diff --git a/frappe/custom/report/audit_system_hooks/test_audit_system_hooks.py b/frappe/custom/report/audit_system_hooks/test_audit_system_hooks.py new file mode 100644 index 0000000000..cd3edffc77 --- /dev/null +++ b/frappe/custom/report/audit_system_hooks/test_audit_system_hooks.py @@ -0,0 +1,17 @@ +# Copyright (c) 2022, Frappe Technologies and contributors +# For license information, please see license.txt + + +from frappe.custom.report.audit_system_hooks.audit_system_hooks import execute +from frappe.tests.utils import FrappeTestCase + + +class TestAuditSystemHooksReport(FrappeTestCase): + def test_basic_query(self): + _, data = execute() + for row in data: + if row.get("hook_name") == "app_name": + self.assertEqual(row.get("hook_values"), "frappe") + break + else: + self.fail("Failed to generate hooks report") From 80dcf0b1788088bb1d469766deeabcd85bc0d7dc Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Sun, 29 Jan 2023 20:09:45 +0530 Subject: [PATCH 202/407] feat: Split dict hooks to separate lines --- .../audit_system_hooks/audit_system_hooks.py | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/frappe/custom/report/audit_system_hooks/audit_system_hooks.py b/frappe/custom/report/audit_system_hooks/audit_system_hooks.py index 8dab192a14..a42c5c361a 100644 --- a/frappe/custom/report/audit_system_hooks/audit_system_hooks.py +++ b/frappe/custom/report/audit_system_hooks/audit_system_hooks.py @@ -5,8 +5,6 @@ import frappe def execute(filters=None): - frappe.only_for("System Manager") - return get_columns(), get_data() @@ -14,8 +12,8 @@ def get_columns(): values_field_type = "Data" # TODO: better text wrapping in reportview columns = [ {"label": "Hook name", "fieldname": "hook_name", "fieldtype": "Data", "width": 200}, - {"label": "Hook key (optional)", "fieldname": "key", "fieldtype": "Data", "width": 200}, - {"label": "Hook Values", "fieldname": "hook_values", "fieldtype": values_field_type}, + {"label": "Hook key (optional)", "fieldname": "hook_key", "fieldtype": "Data", "width": 200}, + {"label": "Hook Values (resolved)", "fieldname": "hook_values", "fieldtype": values_field_type}, ] # Each app is shown in order as a column @@ -37,8 +35,7 @@ def get_data(): if not v: return "" - if isinstance(v, list) and len(v) == 1: - return str(v[0]) + v = delist(v) if isinstance(v, (dict, list)): try: @@ -50,11 +47,24 @@ def get_data(): data = [] for hook, values in hooks.items(): - row = {"hook_name": hook, "hook_values": fmt_hook_values(values)} + if isinstance(values, dict): + for k, v in values.items(): + row = {"hook_name": hook, "hook_key": fmt_hook_values(k), "hook_values": fmt_hook_values(v)} + for app in installed_apps: + if app_hooks := delist(frappe.get_hooks(hook, app_name=app)): + row[app] = fmt_hook_values(app_hooks.get(k)) + data.append(row) + else: + row = {"hook_name": hook, "hook_values": fmt_hook_values(values)} + for app in installed_apps: + row[app] = fmt_hook_values(frappe.get_hooks(hook, app_name=app)) - for app in installed_apps: - row[app] = fmt_hook_values(frappe.get_hooks(hook, app_name=app)) - - data.append(row) + data.append(row) return data + + +def delist(val): + if isinstance(val, list) and len(val) == 1: + return val[0] + return val From e18188ed3ed2a71997e756bf79999092762948e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=BCrker=20Tunal=C4=B1?= Date: Mon, 30 Jan 2023 07:08:35 +0300 Subject: [PATCH 203/407] fix(i18n): Datepicker Turkish translations (#19777) * feat:Datepicker Turkish translations are added. * chore: format [skip ci] --------- --- .../frappe/form/controls/datepicker_i18n.js | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/frappe/public/js/frappe/form/controls/datepicker_i18n.js b/frappe/public/js/frappe/form/controls/datepicker_i18n.js index eca5a61723..ff0c4ffe50 100644 --- a/frappe/public/js/frappe/form/controls/datepicker_i18n.js +++ b/frappe/public/js/frappe/form/controls/datepicker_i18n.js @@ -136,3 +136,44 @@ import "air-datepicker/dist/js/i18n/datepicker.zh.js"; firstDay: 1, }; })(jQuery); + +(function ($) { + $.fn.datepicker.language["tr"] = { + days: ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"], + daysShort: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"], + daysMin: ["Pz", "Pt", "Sa", "Ça", "Pe", "Cu", "Ct"], + months: [ + "Ocak", + "Şubat", + "Mart", + "Nisan", + "Mayıs", + "Haziran", + "Temmuz", + "Ağustos", + "Eylül", + "Ekim", + "Kasım", + "Aralık", + ], + monthsShort: [ + "Oca", + "Şub", + "Mar", + "Nis", + "May", + "Haz", + "Tem", + "Ağu", + "Eyl", + "Eki", + "Kas", + "Ara", + ], + today: "Bugün", + clear: "Temizle", + dateFormat: "dd.mm.yyyy", + timeFormat: "hh:ii", + firstDay: 1, + }; +})(jQuery); From 1e4d28cc196cee68895fbc56a4f0407362c0bec2 Mon Sep 17 00:00:00 2001 From: marination Date: Fri, 27 Jan 2023 16:11:00 +0530 Subject: [PATCH 204/407] fix(test): Remove try-finally & ignore perms on test user's report insertion --- frappe/tests/test_boot.py | 83 +++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 42 deletions(-) diff --git a/frappe/tests/test_boot.py b/frappe/tests/test_boot.py index 7baebac140..ece2c181df 100644 --- a/frappe/tests/test_boot.py +++ b/frappe/tests/test_boot.py @@ -28,49 +28,48 @@ class TestBootData(FrappeTestCase): self.assertListEqual(unseen_notes, []) def test_get_user_pages_or_reports_with_permission_query(self): - try: - # Create a ToDo custom report with admin user - frappe.set_user("Administrator") - frappe.get_doc( - { - "doctype": "Report", - "ref_doctype": "ToDo", - "report_name": "Test Admin Report", - "report_type": "Report Builder", - "is_standard": "No", - } - ).insert() + # Create a ToDo custom report with admin user + frappe.set_user("Administrator") + frappe.get_doc( + { + "doctype": "Report", + "ref_doctype": "ToDo", + "report_name": "Test Admin Report", + "report_type": "Report Builder", + "is_standard": "No", + } + ).insert() - # Add permission query such that each user can only see their own custom reports - frappe.get_doc( - dict( - doctype="Server Script", - name="test_report_permission_query", - script_type="Permission Query", - reference_doctype="Report", - script="""conditions = f"(`tabReport`.is_standard = 'Yes' or `tabReport`.owner = '{frappe.session.user}')" - """, - ) - ).insert() + # Add permission query such that each user can only see their own custom reports + frappe.get_doc( + dict( + doctype="Server Script", + name="test_report_permission_query", + script_type="Permission Query", + reference_doctype="Report", + script="""conditions = f"(`tabReport`.is_standard = 'Yes' or `tabReport`.owner = '{frappe.session.user}')" + """, + ) + ).insert() - # Create a ToDo custom report with test user - frappe.set_user("test@example.com") - frappe.get_doc( - { - "doctype": "Report", - "ref_doctype": "ToDo", - "report_name": "Test User Report", - "report_type": "Report Builder", - "is_standard": "No", - } - ).insert() + # Create a ToDo custom report with test user + frappe.set_user("test@example.com") + frappe.get_doc( + { + "doctype": "Report", + "ref_doctype": "ToDo", + "report_name": "Test User Report", + "report_type": "Report Builder", + "is_standard": "No", + } + ).insert(ignore_permissions=True) - get_user_pages_or_reports("Report") - allowed_reports = frappe.cache().get_value("has_role:Report", user=frappe.session.user) + get_user_pages_or_reports("Report") + allowed_reports = frappe.cache().get_value("has_role:Report", user=frappe.session.user) - # Test user must not see admin user's report - self.assertNotIn("Test Admin Report", allowed_reports) - self.assertIn("Test User Report", allowed_reports) - finally: - frappe.db.rollback() - frappe.set_user("Administrator") + # Test user must not see admin user's report + self.assertNotIn("Test Admin Report", allowed_reports) + self.assertIn("Test User Report", allowed_reports) + + self.addCleanup(frappe.db.rollback) + self.addCleanup(frappe.set_user, "Administrator") From 13162d8fbdbdfbc531b7bfb35b911c5bf22a8444 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 30 Jan 2023 11:24:16 +0530 Subject: [PATCH 205/407] fix: Only apply perm query to non-admin users --- frappe/boot.py | 4 ++-- frappe/tests/test_boot.py | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/frappe/boot.py b/frappe/boot.py index 8eed64b2dc..de3753f754 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -259,8 +259,8 @@ def _run_with_permission_query(query: "Query", doctype: str) -> list[dict]: Note: Works only if 'WHERE' is the last clause in the query """ permission_query = DatabaseQuery(doctype, frappe.session.user).get_permission_query_conditions() - if permission_query: - return frappe.db.sql(f"{query} AND {permission_query}", as_dict=True) # nosemgrep + if permission_query and frappe.session.user != "Administrator": + return frappe.db.sql(f"{query} AND {permission_query}", as_dict=True) return query.run(as_dict=True) diff --git a/frappe/tests/test_boot.py b/frappe/tests/test_boot.py index ece2c181df..232c379e08 100644 --- a/frappe/tests/test_boot.py +++ b/frappe/tests/test_boot.py @@ -70,6 +70,3 @@ class TestBootData(FrappeTestCase): # Test user must not see admin user's report self.assertNotIn("Test Admin Report", allowed_reports) self.assertIn("Test User Report", allowed_reports) - - self.addCleanup(frappe.db.rollback) - self.addCleanup(frappe.set_user, "Administrator") From 1e115e5e50164fe6dbc8c29b61ee86de971de344 Mon Sep 17 00:00:00 2001 From: Sabu Siyad Date: Mon, 30 Jan 2023 12:09:05 +0530 Subject: [PATCH 206/407] chore!: Remove translation tool (page) (#19786) * refactor(page): remove translation tool files * refactor(patches): remove translation tool page --- frappe/desk/page/translation_tool/__init__.py | 0 .../translation_tool/translation_tool.css | 37 -- .../translation_tool/translation_tool.html | 20 - .../page/translation_tool/translation_tool.js | 473 ------------------ .../translation_tool/translation_tool.json | 26 - frappe/patches.txt | 1 + 6 files changed, 1 insertion(+), 556 deletions(-) delete mode 100644 frappe/desk/page/translation_tool/__init__.py delete mode 100644 frappe/desk/page/translation_tool/translation_tool.css delete mode 100644 frappe/desk/page/translation_tool/translation_tool.html delete mode 100644 frappe/desk/page/translation_tool/translation_tool.js delete mode 100644 frappe/desk/page/translation_tool/translation_tool.json diff --git a/frappe/desk/page/translation_tool/__init__.py b/frappe/desk/page/translation_tool/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frappe/desk/page/translation_tool/translation_tool.css b/frappe/desk/page/translation_tool/translation_tool.css deleted file mode 100644 index 9603a4ce35..0000000000 --- a/frappe/desk/page/translation_tool/translation_tool.css +++ /dev/null @@ -1,37 +0,0 @@ -.translation-item { - font-size: 12px; - padding: 12px 15px; - min-height: 40px; - cursor: pointer; - overflow: hidden; -} - -.translation-item:hover { - background-color: #fafbfc; -} -.translation-item.active { - background-color: #fffce7; -} - -.translation-edit-section { - height: 100%; - overflow-y: scroll; - padding: 0px; -} - -.translation-tool { - display: flex; - width: 100%; - padding: 0; - height: 72vh; -} - -.left-side { - padding: 0px; - height: 100%; - overflow-y: scroll; -} - -.contributed-translation { - padding: 0.5rem 0; -} diff --git a/frappe/desk/page/translation_tool/translation_tool.html b/frappe/desk/page/translation_tool/translation_tool.html deleted file mode 100644 index a88f698584..0000000000 --- a/frappe/desk/page/translation_tool/translation_tool.html +++ /dev/null @@ -1,20 +0,0 @@ -
-
-
-
- {%= __("Contributed Translations") %} -
-
-
-
-
- {%= __("Source Text") %} -
-
-
-
-
-
-
-
-
diff --git a/frappe/desk/page/translation_tool/translation_tool.js b/frappe/desk/page/translation_tool/translation_tool.js deleted file mode 100644 index 5739eddfc7..0000000000 --- a/frappe/desk/page/translation_tool/translation_tool.js +++ /dev/null @@ -1,473 +0,0 @@ -frappe.pages["translation-tool"].on_page_load = function (wrapper) { - var page = frappe.ui.make_app_page({ - parent: wrapper, - title: __("Translation Tool"), - single_column: true, - card_layout: true, - }); - - frappe.translation_tool = new TranslationTool(page); -}; - -class TranslationTool { - constructor(page) { - this.page = page; - this.wrapper = $(page.body); - this.wrapper.append(frappe.render_template("translation_tool")); - frappe.utils.bind_actions_with_object(this.wrapper, this); - this.active_translation = null; - this.edited_translations = {}; - this.setup_search_box(); - this.setup_language_filter(); - this.page.set_primary_action( - __("Contribute Translations"), - this.show_confirmation_dialog.bind(this) - ); - this.page.set_secondary_action(__("Refresh"), this.fetch_messages_then_render.bind(this)); - this.update_header(); - } - - setup_language_filter() { - let languages = Object.keys(frappe.boot.lang_dict).map((language_label) => { - let value = frappe.boot.lang_dict[language_label]; - return { - label: `${language_label} (${value})`, - value: value, - }; - }); - - let language_selector = this.page.add_field({ - fieldname: "language", - fieldtype: "Select", - options: languages, - change: () => { - let language = language_selector.get_value(); - localStorage.setItem("translation_language", language); - this.language = language; - this.fetch_messages_then_render(); - }, - }); - let translation_language = localStorage.getItem("translation_language"); - if (translation_language || frappe.boot.lang !== "en") { - language_selector.set_value(translation_language || frappe.boot.lang); - } else { - frappe.prompt( - { - label: __("Please select target language for translation"), - fieldname: "language", - fieldtype: "Select", - options: languages, - reqd: 1, - }, - (values) => { - language_selector.set_value(values.language); - }, - __("Select Language") - ); - } - } - - setup_search_box() { - let search_box = this.page.add_field({ - fieldname: "search", - fieldtype: "Data", - label: __("Search Source Text"), - change: () => { - this.search_text = search_box.get_value(); - this.fetch_messages_then_render(); - }, - }); - } - - fetch_messages_then_render() { - this.fetch_messages().then((messages) => { - this.messages = messages; - this.render_messages(messages); - }); - this.setup_local_contributions(); - } - - fetch_messages() { - frappe.dom.freeze(__("Fetching...")); - return frappe - .xcall("frappe.translate.get_messages", { - language: this.language, - search_text: this.search_text, - }) - .then((messages) => { - return messages; - }) - .finally(() => { - frappe.dom.unfreeze(); - }); - } - - render_messages(messages) { - let template = (message) => ` -
-
- - ${frappe.utils.escape_html(message.source_text)} - -
-
- `; - - let html = messages.map(template).join(""); - this.wrapper.find(".translation-item-container").html(html); - } - - on_translation_click(e, $el) { - let message_id = decodeURIComponent($el.data("message-id")); - this.wrapper.find(".translation-item").removeClass("active"); - $el.addClass("active"); - this.active_translation = this.messages.find((m) => m.id === message_id); - this.edit_translation(this.active_translation); - } - - edit_translation(translation) { - if (this.form) { - this.form.set_values({}); - } - this.get_additional_info(translation.id).then((data) => { - this.make_edit_form(translation, data); - }); - } - - get_additional_info(source_id) { - frappe.dom.freeze("Fetching..."); - return frappe - .xcall("frappe.translate.get_source_additional_info", { - source: source_id, - language: this.page.fields_dict["language"].get_value(), - }) - .finally(frappe.dom.unfreeze); - } - - make_edit_form(translation, { contributions, positions }) { - if (!this.form) { - this.form = new frappe.ui.FieldGroup({ - fields: [ - { - fieldtype: "HTML", - fieldname: "header", - read_only: 1, - }, - { - fieldtype: "Data", - fieldname: "id", - hidden: 1, - }, - { - label: "Source Text", - fieldtype: "Code", - fieldname: "source_text", - read_only: 1, - enable_copy_button: 1, - }, - { - label: "Context", - fieldtype: "Code", - fieldname: "context", - read_only: 1, - }, - { - label: "DocType", - fieldtype: "Data", - fieldname: "doctype", - read_only: 1, - }, - { - label: "Translated Text", - fieldtype: "Small Text", - fieldname: "translated_text", - }, - { - label: "Suggest", - fieldtype: "Button", - click: () => { - let { id, translated_text, source_text } = this.form.get_values(); - let existing_value = this.form.translation_dict.translated_text; - if (is_null(translated_text) || existing_value === translated_text) { - delete this.edited_translations[id]; - } else if (existing_value !== translated_text) { - this.edited_translations[id] = { - id, - translated_text, - source_text, - }; - } - this.update_header(); - }, - }, - { - fieldtype: "Section Break", - fieldname: "contributed_translations_section", - label: "Contributed Translations", - }, - { - fieldtype: "HTML", - fieldname: "contributed_translations", - }, - { - fieldtype: "Section Break", - collapsible: 1, - label: "Occurences in source code", - }, - { - fieldtype: "HTML", - fieldname: "positions", - }, - ], - body: this.wrapper.find(".translation-edit-form"), - }); - - this.form.make(); - this.setup_header(); - } - - this.form.set_values(translation); - this.form.translation_dict = translation; - this.form.set_df_property("doctype", "hidden", !translation.doctype); - this.form.set_df_property("context", "hidden", !translation.context); - this.set_status(translation); - - this.setup_contributions(contributions); - this.setup_positions(positions); - } - - setup_header() { - this.form.get_field("header").$wrapper.html(`
- -
`); - } - - set_status(translation) { - this.form.get_field("header").$wrapper.find(".translation-status").html(` - - ${this.get_indicator_status_text(translation)} - - `); - } - - setup_positions(positions) { - let position_dom = ""; - if (positions && positions.length) { - position_dom = positions - .map((position) => { - if (position.path.startsWith("DocType: ")) { - return `
- ${position.path} -
`; - } else { - return ``; - } - }) - .join(""); - } - this.form.get_field("positions").$wrapper.html(position_dom); - } - - setup_contributions(contributions) { - const contributions_exists = contributions && contributions.length; - if (contributions_exists) { - let contributions_html = contributions.map((c) => { - return ` -
-
${c.translated}
-
- ${comment_when(c.creation)} -
-
- `; - }); - this.form.get_field("contributed_translations").html(contributions_html); - } - this.form.set_df_property( - "contributed_translations_section", - "hidden", - !contributions_exists - ); - } - show_confirmation_dialog() { - this.confirmation_dialog = new frappe.ui.Dialog({ - fields: [ - { - label: __("Language"), - fieldname: "language", - fieldtype: "Data", - read_only: 1, - bold: 1, - default: this.language, - }, - { - fieldtype: "HTML", - fieldname: "edited_translations", - }, - ], - title: __("Confirm Translations"), - no_submit_on_enter: true, - primary_action_label: __("Submit"), - primary_action: (values) => { - this.create_translations(values).then(this.confirmation_dialog.hide()); - }, - }); - this.confirmation_dialog.get_field("edited_translations").html(` - - - - - - ${Object.values(this.edited_translations) - .map( - (t) => ` - - - - - ` - ) - .join("")} -
${__("Source Text")}${__("Translated Text")}
${t.source_text}${t.translated_text}
- `); - this.confirmation_dialog.show(); - } - create_translations() { - frappe.dom.freeze(__("Submitting...")); - return frappe - .xcall("frappe.core.doctype.translation.translation.create_translations", { - translation_map: this.edited_translations, - language: this.language, - }) - .then(() => { - frappe.dom.unfreeze(); - frappe.show_alert({ - message: __("Successfully Submitted!"), - indicator: "success", - }); - this.edited_translations = {}; - this.update_header(); - this.fetch_messages_then_render(); - }) - .finally(() => frappe.dom.unfreeze()); - } - - setup_local_contributions() { - // TODO: Refactor - frappe - .xcall("frappe.translate.get_contributions", { - language: this.language, - }) - .then((messages) => { - let template = (message) => ` -
-
- - ${frappe.utils.escape_html(message.source_text)} - -
-
- `; - - let html = messages.map(template).join(""); - this.wrapper.find(".translation-item-tr").html(html); - }); - } - - show_translation_status_modal(e, $el) { - let message_id = decodeURIComponent($el.data("message-id")); - - frappe.xcall("frappe.translate.get_contribution_status", { message_id }).then((doc) => { - let d = new frappe.ui.Dialog({ - title: __("Contribution Status"), - fields: [ - { - fieldname: "source_message", - label: __("Source Message"), - fieldtype: "Data", - read_only: 1, - }, - { - fieldname: "translated", - label: __("Translated Message"), - fieldtype: "Data", - read_only: 1, - }, - { - fieldname: "contribution_status", - label: __("Contribution Status"), - fieldtype: "Data", - read_only: 1, - }, - { - fieldname: "modified_by", - label: __("Verified By"), - fieldtype: "Data", - read_only: 1, - depends_on: (doc) => { - return doc.contribution_status == "Verified"; - }, - }, - ], - }); - d.set_values(doc); - d.show(); - }); - } - - update_header() { - let edited_translations_count = Object.keys(this.edited_translations).length; - if (edited_translations_count) { - let message = ""; - if (edited_translations_count == 1) { - message = __("{0} translation pending", [edited_translations_count]); - } else { - message = __("{0} translations pending", [edited_translations_count]); - } - this.page.set_indicator(message, "orange"); - } else { - this.page.set_indicator(""); - } - this.page.btn_primary.prop("disabled", !edited_translations_count); - } - - get_indicator_color(message_obj) { - return !message_obj.translated - ? "red" - : message_obj.translated_by_google - ? "orange" - : "blue"; - } - - get_indicator_status_text(message_obj) { - if (!message_obj.translated) { - return __("Untranslated"); - } else if (message_obj.translated_by_google) { - return __("Google Translation"); - } else { - return __("Community Contribution"); - } - } - - get_contribution_indicator_color(message_obj) { - return message_obj.contribution_status == "Pending" ? "orange" : "green"; - } - - get_code_url(path, line_no, app) { - const code_path = path.substring(`apps/${app}`.length); - return `https://github.com/frappe/${app}/blob/develop/${code_path}#L${line_no}`; - } -} diff --git a/frappe/desk/page/translation_tool/translation_tool.json b/frappe/desk/page/translation_tool/translation_tool.json deleted file mode 100644 index a54b2a4724..0000000000 --- a/frappe/desk/page/translation_tool/translation_tool.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "content": null, - "creation": "2020-01-30 15:16:12.136323", - "docstatus": 0, - "doctype": "Page", - "idx": 0, - "modified": "2020-01-30 15:16:23.273733", - "modified_by": "Administrator", - "module": "Desk", - "name": "translation-tool", - "owner": "Administrator", - "page_name": "Translation Tool", - "roles": [ - { - "role": "System Manager" - }, - { - "role": "Translator" - } - ], - "script": null, - "standard": "Yes", - "style": null, - "system_page": 1, - "title": "Translation Tool" -} \ No newline at end of file diff --git a/frappe/patches.txt b/frappe/patches.txt index 49b43f0558..ec55c7fedd 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -220,4 +220,5 @@ frappe.patches.v14_0.update_attachment_comment frappe.patches.v15_0.set_contact_full_name execute:frappe.delete_doc("Page", "activity", force=1) frappe.patches.v14_0.disable_email_accounts_with_oauth +execute:frappe.delete_doc("Page", "translation-tool", force=1) frappe.patches.v15_0.remove_prepared_report_settings_from_system_settings From e4f818b83827fcd82ff0a09401597d984c5cf9d4 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 30 Jan 2023 12:41:46 +0530 Subject: [PATCH 207/407] fix: remove row if search_row is not implemented --- frappe/public/js/frappe/form/grid.js | 2 ++ frappe/public/js/frappe/form/grid_row.js | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index 9e7a8b58aa..b767dac932 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -582,6 +582,8 @@ export default class Grid { get_filtered_data() { let all_data = this.frm ? this.frm.doc[this.df.fieldname] : this.df.data; + if (!all_data) return; + for (const field in this.filter) { all_data = all_data.filter((data) => { let { df, value } = this.filter[field]; diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js index 35caaa667f..ec235370fc 100644 --- a/frappe/public/js/frappe/form/grid_row.js +++ b/frappe/public/js/frappe/form/grid_row.js @@ -12,7 +12,8 @@ export default class GridRow { this.make(); } make() { - var me = this; + let me = this; + let render_row = true; this.wrapper = $('
'); this.row = $('
') @@ -36,9 +37,11 @@ export default class GridRow { if (this.grid.template && !this.grid.meta.editable_grid) { this.render_template(); } else { - this.render_row(); + render_row = this.render_row(); } + if (!this.render_row) return; + this.set_data(); this.wrapper.appendTo(this.parent); } @@ -312,6 +315,8 @@ export default class GridRow { if (this.frm && this.doc) { $(this.frm.wrapper).trigger("grid-row-render", [this]); } + + return true; } make_editable() { @@ -756,7 +761,9 @@ export default class GridRow { } show_search_row() { + // show or remove search columns based on grid rows this.show_search = this.show_search && this.grid.data.length >= 20; + !this.show_search && this.wrapper.remove(); return this.show_search; } From 1eab4e425340fabc2c5c25d91c0bd05de67df667 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 30 Jan 2023 14:11:38 +0530 Subject: [PATCH 208/407] fix: Convert doctype name to string (#19832) --- frappe/core/doctype/docshare/test_docshare.py | 14 ++++++++++++++ frappe/permissions.py | 2 +- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/docshare/test_docshare.py b/frappe/core/doctype/docshare/test_docshare.py index f2ed8a32af..b874042d15 100644 --- a/frappe/core/doctype/docshare/test_docshare.py +++ b/frappe/core/doctype/docshare/test_docshare.py @@ -125,3 +125,17 @@ class TestDocShare(FrappeTestCase): ) frappe.share.remove(doctype, submittable_doc.name, self.user) + + def test_share_int_pk(self): + test_doc = frappe.new_doc("Console Log") + + test_doc.insert() + frappe.share.add("Console Log", test_doc.name, self.user) + + frappe.set_user(self.user) + self.assertIn( + str(test_doc.name), [str(name) for name in frappe.get_list("Console Log", pluck="name")] + ) + + test_doc.reload() + self.assertTrue(test_doc.has_permission("read")) diff --git a/frappe/permissions.py b/frappe/permissions.py index ef33c03875..2bee75d50c 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -637,7 +637,7 @@ def get_linked_doctypes(dt: str) -> list: def get_doc_name(doc): if not doc: return None - return doc if isinstance(doc, str) else doc.name + return doc if isinstance(doc, str) else str(doc.name) def allow_everything(): From 3e20e7df259b15840379f668790adcf3507b1065 Mon Sep 17 00:00:00 2001 From: Leonard Goertz <49870752+uepselon@users.noreply.github.com> Date: Mon, 30 Jan 2023 09:44:11 +0100 Subject: [PATCH 209/407] fix: add brackets for docshare or condition (#19650) Co-authored-by: Leonard Goertz --- frappe/model/db_query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index efdad83f13..36bd325e26 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -779,7 +779,7 @@ class DatabaseQuery: # share is an OR condition, if there is a role permission if not only_if_shared and self.shared and conditions: - conditions = f"({conditions}) or ({self.get_share_condition()})" + conditions = f"(({conditions}) or ({self.get_share_condition()}))" return conditions From 8be98718f7406602464e8b17de4b06054ab6f855 Mon Sep 17 00:00:00 2001 From: RJPvT <48353029+RJPvT@users.noreply.github.com> Date: Mon, 30 Jan 2023 10:14:53 +0100 Subject: [PATCH 210/407] fix: ldap with 2fa (#19753) Because we pop password 2fa fails when used with ldap Co-authored-by: Ankush Menat --- frappe/integrations/doctype/ldap_settings/ldap_settings.py | 4 +++- frappe/utils/error.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frappe/integrations/doctype/ldap_settings/ldap_settings.py b/frappe/integrations/doctype/ldap_settings/ldap_settings.py index 21e5c5b312..5b8ad1c901 100644 --- a/frappe/integrations/doctype/ldap_settings/ldap_settings.py +++ b/frappe/integrations/doctype/ldap_settings/ldap_settings.py @@ -370,13 +370,15 @@ def login(): args = frappe.form_dict ldap: LDAPSettings = frappe.get_doc("LDAP Settings") - user = ldap.authenticate(frappe.as_unicode(args.usr), frappe.as_unicode(args.pop("pwd", None))) + user = ldap.authenticate(frappe.as_unicode(args.usr), frappe.as_unicode(args.pwd)) frappe.local.login_manager.user = user.name if should_run_2fa(user.name): authenticate_for_2factor(user.name) if not confirm_otp_token(frappe.local.login_manager): return False + + frappe.form_dict.pop("pwd", None) frappe.local.login_manager.post_login() # because of a GET request! diff --git a/frappe/utils/error.py b/frappe/utils/error.py index 432591175c..235a9b3e67 100644 --- a/frappe/utils/error.py +++ b/frappe/utils/error.py @@ -12,7 +12,7 @@ import pydoc import sys import traceback -from ldap3.core.exceptions import LDAPInvalidCredentialsResult +from ldap3.core.exceptions import LDAPException import frappe from frappe.utils import cstr, encode @@ -21,7 +21,7 @@ EXCLUDE_EXCEPTIONS = ( frappe.AuthenticationError, frappe.CSRFTokenError, # CSRF covers OAuth too frappe.SecurityException, - LDAPInvalidCredentialsResult, + LDAPException, ) From e82046ef00b719f3508fd9897d8e1c1aaf902105 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Mon, 30 Jan 2023 10:41:55 +0100 Subject: [PATCH 211/407] ci: bump isort to 5.12.0 (#19836) * ci: bump isort to 5.12.0 * style: remove trailing whitespace --- .pre-commit-config.yaml | 4 ++-- frappe/desk/form/load.py | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4b3ea6d1ea..0c6bbe8ec9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -48,8 +48,8 @@ repos: )$ - - repo: https://github.com/timothycrosley/isort - rev: 5.9.1 + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 hooks: - id: isort diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 81f5096f29..3627f48109 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -39,7 +39,6 @@ def getdoc(doctype, name, user=None): ) raise frappe.PermissionError(("read", doctype, name)) - run_onload(doc) doc.apply_fieldlevel_read_permissions() From 338ccc5a2a11164854540492857a3f178d448d1d Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 30 Jan 2023 15:22:21 +0530 Subject: [PATCH 212/407] fix: sanitize form dict in error logs (#19835) [skip ci] --- .../error_snapshot/test_error_snapshot.py | 4 ++- frappe/utils/__init__.py | 4 +-- frappe/utils/logger.py | 25 ++++++++++++++++++- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/frappe/core/doctype/error_snapshot/test_error_snapshot.py b/frappe/core/doctype/error_snapshot/test_error_snapshot.py index 8ff48bc5c6..4779d56c7b 100644 --- a/frappe/core/doctype/error_snapshot/test_error_snapshot.py +++ b/frappe/core/doctype/error_snapshot/test_error_snapshot.py @@ -1,9 +1,11 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE from frappe.tests.utils import FrappeTestCase +from frappe.utils.logger import sanitized_dict # test_records = frappe.get_test_records('Error Snapshot') class TestErrorSnapshot(FrappeTestCase): - pass + def test_form_dict_sanitization(self): + self.assertNotEqual(sanitized_dict({"pwd": "SECRET", "usr": "WHAT"}).get("pwd"), "SECRET") diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index d82c039b95..d37e8c201f 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -305,7 +305,7 @@ def get_traceback(with_context=False) -> str: return "" if with_context: - trace_list = iter_exc_lines(fmt=_get_sanitizer()) + trace_list = iter_exc_lines(fmt=_get_traceback_sanitizer()) tb = "\n".join(trace_list) else: trace_list = traceback.format_exception(exc_type, exc_value, exc_tb) @@ -316,7 +316,7 @@ def get_traceback(with_context=False) -> str: @functools.lru_cache(maxsize=1) -def _get_sanitizer(): +def _get_traceback_sanitizer(): from traceback_with_variables import Format blocklist = [ diff --git a/frappe/utils/logger.py b/frappe/utils/logger.py index b642821c9d..ddb81f3d79 100755 --- a/frappe/utils/logger.py +++ b/frappe/utils/logger.py @@ -1,6 +1,7 @@ # imports - standard imports import logging import os +from copy import deepcopy from logging.handlers import RotatingFileHandler from typing import Literal @@ -91,7 +92,7 @@ class SiteContextFilter(logging.Filter): def filter(self, record) -> bool: if "Form Dict" not in str(record.msg): site = getattr(frappe.local, "site", None) - form_dict = getattr(frappe.local, "form_dict", None) + form_dict = sanitized_dict(getattr(frappe.local, "form_dict", None)) record.msg = str(record.msg) + f"\nSite: {site}\nForm Dict: {form_dict}" return True @@ -100,3 +101,25 @@ def set_log_level(level: Literal["ERROR", "WARNING", "WARN", "INFO", "DEBUG"]) - """Use this method to set log level to something other than the default DEBUG""" frappe.log_level = getattr(logging, (level or "").upper(), None) or default_log_level frappe.loggers = {} + + +def sanitized_dict(form_dict): + if not isinstance(form_dict, dict): + return form_dict + + sanitized_dict = deepcopy(form_dict) + + blocklist = [ + "password", + "passwd", + "secret", + "token", + "key", + "pwd", + ] + + for k in sanitized_dict: + for secret_kw in blocklist: + if secret_kw in k: + sanitized_dict[k] = "********" + return sanitized_dict From 45280d48013534c0f33098f1c67abb98e1b18e5b Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 31 Jan 2023 11:25:14 +0530 Subject: [PATCH 213/407] fix: website theme caching (#19848) --- .../website_theme/test_website_theme.py | 55 ++++++++++++------- .../doctype/website_theme/website_theme.py | 14 +---- 2 files changed, 36 insertions(+), 33 deletions(-) diff --git a/frappe/website/doctype/website_theme/test_website_theme.py b/frappe/website/doctype/website_theme/test_website_theme.py index 80456a71e4..045b063221 100644 --- a/frappe/website/doctype/website_theme/test_website_theme.py +++ b/frappe/website/doctype/website_theme/test_website_theme.py @@ -1,40 +1,53 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE -import os +from contextlib import contextmanager import frappe from frappe.tests.utils import FrappeTestCase +from frappe.website.doctype.website_theme.website_theme import ( + after_migrate, + get_active_theme, + get_scss_paths, +) -from .website_theme import get_scss_paths + +@contextmanager +def website_theme_fixture(**theme): + test_theme = "test-theme" + + frappe.delete_doc_if_exists("Website Theme", test_theme) + theme = frappe.get_doc(doctype="Website Theme", theme=test_theme, **theme) + theme.insert() + yield theme + theme.delete(force=True) class TestWebsiteTheme(FrappeTestCase): def test_website_theme(self): - frappe.delete_doc_if_exists("Website Theme", "test-theme") - theme = frappe.get_doc( - dict( - doctype="Website Theme", - theme="test-theme", - google_font="Inter", - custom_scss="body { font-size: 16.5px; }", # this will get minified! - ) - ).insert() + with website_theme_fixture( + google_font="Inter", + custom_scss="body { font-size: 16.5px; }", # this will get minified! + ) as theme: - theme_path = frappe.get_site_path("public", theme.theme_url[1:]) - with open(theme_path) as theme_file: - css = theme_file.read() + theme_path = frappe.get_site_path("public", theme.theme_url[1:]) + with open(theme_path) as theme_file: + css = theme_file.read() - self.assertTrue("body{font-size:16.5px}" in css) - self.assertTrue("fonts.googleapis.com" in css) + self.assertTrue("body{font-size:16.5px}" in css) + self.assertTrue("fonts.googleapis.com" in css) def test_get_scss_paths(self): self.assertIn("frappe/public/scss/website.bundle", get_scss_paths()) def test_imports_to_ignore(self): - frappe.delete_doc_if_exists("Website Theme", "test-theme") - theme = frappe.get_doc( - dict(doctype="Website Theme", theme="test-theme", ignored_apps=[{"app": "frappe"}]) - ).insert() + with website_theme_fixture(ignored_apps=[{"app": "frappe"}]) as theme: + self.assertTrue('@import "frappe/public/scss/website"' not in theme.theme_scss) - self.assertTrue('@import "frappe/public/scss/website"' not in theme.theme_scss) + def test_after_migrate_hook(self): + with website_theme_fixture(google_font="Inter") as theme: + theme.set_as_default() + before = get_active_theme().theme_url + after_migrate() + after = get_active_theme().theme_url + self.assertNotEqual(before, after) diff --git a/frappe/website/doctype/website_theme/website_theme.py b/frappe/website/doctype/website_theme/website_theme.py index 7abfab93e3..178e872c2f 100644 --- a/frappe/website/doctype/website_theme/website_theme.py +++ b/frappe/website/doctype/website_theme/website_theme.py @@ -99,18 +99,8 @@ class WebsiteTheme(Document): if fname.startswith(frappe.scrub(self.name) + "_") and fname.endswith(".css"): os.remove(os.path.join(folder_path, fname)) - def generate_theme_if_not_exist(self): - bench_path = frappe.utils.get_bench_path() - if self.theme_url: - theme_path = join_path(bench_path, "sites", self.theme_url[1:]) - if not path_exists(theme_path): - self.generate_bootstrap_theme() - else: - self.generate_bootstrap_theme() - @frappe.whitelist() def set_as_default(self): - self.generate_bootstrap_theme() self.save() website_settings = frappe.get_doc("Website Settings") website_settings.website_theme = self.name @@ -133,6 +123,7 @@ def get_active_theme() -> Optional["WebsiteTheme"]: try: return frappe.get_cached_doc("Website Theme", website_theme) except frappe.DoesNotExistError: + frappe.clear_last_message() pass @@ -187,5 +178,4 @@ def after_migrate(): return doc = frappe.get_doc("Website Theme", website_theme) - doc.generate_bootstrap_theme() - doc.save() + doc.save() # Just re-saving re-generates the theme. From b3b846472e8303e6cfff6d240d73accec2a1b771 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 31 Jan 2023 12:35:43 +0530 Subject: [PATCH 214/407] test: clear defaults after test --- frappe/website/doctype/website_theme/test_website_theme.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/website/doctype/website_theme/test_website_theme.py b/frappe/website/doctype/website_theme/test_website_theme.py index 045b063221..b99aa00043 100644 --- a/frappe/website/doctype/website_theme/test_website_theme.py +++ b/frappe/website/doctype/website_theme/test_website_theme.py @@ -20,7 +20,8 @@ def website_theme_fixture(**theme): theme = frappe.get_doc(doctype="Website Theme", theme=test_theme, **theme) theme.insert() yield theme - theme.delete(force=True) + frappe.db.set_single_value("Website Settings", "website_theme", "Standard") + theme.delete() class TestWebsiteTheme(FrappeTestCase): From 5cc21da6a11f05c058d52d171c005f7f588929a0 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 31 Jan 2023 13:06:44 +0530 Subject: [PATCH 215/407] fix: Interface DatabaseQuery to virtual doctypes' --- frappe/model/db_query.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index efdad83f13..6984146d23 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -16,6 +16,7 @@ from frappe.core.doctype.server_script.server_script_utils import get_server_scr from frappe.database.utils import FallBackDateTimeStr, NestedSetHierarchy from frappe.model import optional_fields from frappe.model.meta import get_table_columns +from frappe.model.utils import is_virtual_doctype from frappe.model.utils.user_settings import get_user_settings, update_user_settings from frappe.query_builder.utils import Column from frappe.utils import ( @@ -163,6 +164,13 @@ class DatabaseQuery: if user_settings: self.user_settings = json.loads(user_settings) + if is_virtual_doctype(self.doctype): + from frappe.model.virtual_doctype import get_controller + + controller = get_controller(self.doctype) + self.parse_args() + return controller.get_list(self.__dict__) + self.columns = self.get_table_columns() # no table & ignore_ddl, return From 636c4701cfb41b8129b2b94462f36b1e89d2bc01 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 31 Jan 2023 13:18:27 +0530 Subject: [PATCH 216/407] perf: Batched List Updates * Perform batched list updates for a N documents made every second * list_update callbacks for doc refreshes maintained in cur_list.pending_document_refreshes --- frappe/public/js/frappe/list/list_view.js | 105 +++++++++++++--------- 1 file changed, 62 insertions(+), 43 deletions(-) diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 4625f0aa8e..6716999dcb 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -1320,6 +1320,11 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { } setup_realtime_updates() { + this.pending_document_refreshes = []; + setInterval(() => { + this.process_document_refreshes(); + }, 1000); + if (this.list_view_settings && this.list_view_settings.disable_auto_refresh) { return; } @@ -1333,28 +1338,42 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { return; } - const { doctype, name } = data; - if (doctype !== this.doctype) return; + this.pending_document_refreshes.push(data); + }); + } - // filters to get only the doc with this name - const call_args = this.get_call_args(); - call_args.args.filters.push([this.doctype, "name", "=", name]); - call_args.args.start = 0; + process_document_refreshes() { + if (!this.pending_document_refreshes.length) return; - frappe.call(call_args).then(({ message }) => { - if (!message) return; - const data = frappe.utils.dict(message.keys, message.values); - if (!(data && data.length)) { - // this doc was changed and should not be visible - // in the listview according to filters applied - // let's remove it manually - this.data = this.data.filter((d) => d.name !== name); - this.render_list(); - return; - } + const names = this.pending_document_refreshes + .filter((d) => d.doctype === this.doctype) + .map((d) => d.name); + this.pending_document_refreshes = this.pending_document_refreshes.filter( + (d) => names.indexOf(d.name) === -1 + ); - const datum = data[0]; - const index = this.data.findIndex((d) => d.name === datum.name); + if (!names.length) return; + + // filters to get only the doc with this name + const call_args = this.get_call_args(); + call_args.args.filters.push([this.doctype, "name", "in", names]); + call_args.args.start = 0; + + frappe.call(call_args).then(({ message }) => { + if (!message) return; + const data = frappe.utils.dict(message.keys, message.values); + + if (!(data && data.length)) { + // this doc was changed and should not be visible + // in the listview according to filters applied + // let's remove it manually + this.data = this.data.filter((d) => names.indexOf(d.name) === -1); + this.render_list(); + return; + } + + data.forEach((datum) => { + const index = this.data.findIndex((doc) => doc.name === datum.name); if (index === -1) { // append new data @@ -1363,31 +1382,31 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { // update this data in place this.data[index] = datum; } - - this.data.sort((a, b) => { - const a_value = a[this.sort_by] || ""; - const b_value = b[this.sort_by] || ""; - - let return_value = 0; - if (a_value > b_value) { - return_value = 1; - } - - if (b_value > a_value) { - return_value = -1; - } - - if (this.sort_order === "desc") { - return_value = -return_value; - } - return return_value; - }); - this.toggle_result_area(); - this.render_list(); - if (this.$checks && this.$checks.length) { - this.set_rows_as_checked(); - } }); + + this.data.sort((a, b) => { + const a_value = a[this.sort_by] || ""; + const b_value = b[this.sort_by] || ""; + + let return_value = 0; + if (a_value > b_value) { + return_value = 1; + } + + if (b_value > a_value) { + return_value = -1; + } + + if (this.sort_order === "desc") { + return_value = -return_value; + } + return return_value; + }); + if (this.$checks && this.$checks.length) { + this.set_rows_as_checked(); + } + this.toggle_result_area(); + this.render_list(); }); } From 7f34d510f2ce5ee69cce2c4eabe4306d0118b54b Mon Sep 17 00:00:00 2001 From: gavin Date: Tue, 31 Jan 2023 14:43:26 +0530 Subject: [PATCH 217/407] fix(db_query): Allow link field to have 'tab' (#19820) * fix(db_query): Allow link field to have 'tab' Issue: Occurence of tab was used to check if the selected field is a table name and not a fieldname. This caused DocTypes with fields like `tablets` or `table_name` to break List Views. Change: Check if the field exists in meta to be sure that the selectable is a field. * fix: Split once to ensure at most 2 args --- frappe/model/db_query.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 36bd325e26..a5a4039223 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -317,13 +317,16 @@ class DatabaseQuery: # convert child_table.fieldname to `tabChild DocType`.`fieldname` for field in self.fields: - if "." in field and "tab" not in field: + if "." in field: original_field = field alias = None if " as " in field: - field, alias = field.split(" as ") - linked_fieldname, fieldname = field.split(".") + field, alias = field.split(" as ", 1) + linked_fieldname, fieldname = field.split(".", 1) linked_field = frappe.get_meta(self.doctype).get_field(linked_fieldname) + # this is not a link field + if not linked_field: + continue linked_doctype = linked_field.options if linked_field.fieldtype == "Link": self.append_link_table(linked_doctype, linked_fieldname) From 1fd74e94724c91a789a93d80db4ef4f0361d889a Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 31 Jan 2023 14:53:50 +0530 Subject: [PATCH 218/407] chore: switch base doctype This is a dummy doctype and doesn't actually affect anything but needs to have "report" perm. [skip ci] --- .../custom/report/audit_system_hooks/audit_system_hooks.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/custom/report/audit_system_hooks/audit_system_hooks.json b/frappe/custom/report/audit_system_hooks/audit_system_hooks.json index d9ea86f07f..b13a43a0c5 100644 --- a/frappe/custom/report/audit_system_hooks/audit_system_hooks.json +++ b/frappe/custom/report/audit_system_hooks/audit_system_hooks.json @@ -9,14 +9,14 @@ "idx": 0, "is_standard": "Yes", "letter_head": "", - "modified": "2023-01-25 15:03:31.263337", + "modified": "2023-01-31 14:53:37.778576", "modified_by": "Administrator", "module": "Custom", "name": "Audit System Hooks", "owner": "Administrator", "prepared_report": 0, "query": "", - "ref_doctype": "System Settings", + "ref_doctype": "Property Setter", "report_name": "Audit System Hooks", "report_type": "Script Report", "roles": [ From 9d236fc2cc8e69e1002dc44c06b1b0193dac675a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 31 Jan 2023 15:05:23 +0530 Subject: [PATCH 219/407] fix: handle missing is_virtual column via is_virtual_doctype --- frappe/model/db_query.py | 2 +- frappe/model/utils/__init__.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 6984146d23..af32df8c87 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -165,7 +165,7 @@ class DatabaseQuery: self.user_settings = json.loads(user_settings) if is_virtual_doctype(self.doctype): - from frappe.model.virtual_doctype import get_controller + from frappe.model.base_document import get_controller controller = get_controller(self.doctype) self.parse_args() diff --git a/frappe/model/utils/__init__.py b/frappe/model/utils/__init__.py index bf6804ad05..2935872fc7 100644 --- a/frappe/model/utils/__init__.py +++ b/frappe/model/utils/__init__.py @@ -129,5 +129,7 @@ def get_fetch_values(doctype, fieldname, value): @site_cache() -def is_virtual_doctype(doctype): - return frappe.db.get_value("DocType", doctype, "is_virtual") +def is_virtual_doctype(doctype: str): + if frappe.db.has_column("DocType", "is_virtual"): + return frappe.db.get_value("DocType", doctype, "is_virtual") + return False From 4d08f50a03dbf539b5835fcd0407542afe113b1a Mon Sep 17 00:00:00 2001 From: Samuel Danieli <23150094+scdanieli@users.noreply.github.com> Date: Tue, 31 Jan 2023 11:34:40 +0100 Subject: [PATCH 220/407] fix: PermissionError (#19856) * fix: PermissionError * fix: check perm on customize form --------- Co-authored-by: Ankush Menat --- frappe/printing/doctype/print_format/print_format.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/printing/doctype/print_format/print_format.js b/frappe/printing/doctype/print_format/print_format.js index 94f0ae5b1c..7b746241ac 100644 --- a/frappe/printing/doctype/print_format/print_format.js +++ b/frappe/printing/doctype/print_format/print_format.js @@ -39,7 +39,7 @@ frappe.ui.form.on("Print Format", { } else if (frm.doc.custom_format && !frm.doc.raw_printing) { frm.set_df_property("html", "reqd", 1); } - if (frappe.model.can_read(frm.doc.doc_type)) { + if (frappe.model.can_write("Customize Form")) { frappe.db.get_value("DocType", frm.doc.doc_type, "default_print_format", (r) => { if (r.default_print_format != frm.doc.name) { frm.add_custom_button(__("Set as Default"), function () { From dc940bac1d345c5448bc2077716fa3d41ca353b7 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Tue, 31 Jan 2023 17:07:04 +0530 Subject: [PATCH 221/407] fix: Pass all DatabaseQuery.execute params to virtual doctype's get_list Give parsed args higher priority in kwargs resolution --- frappe/model/db_query.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index af32df8c87..23d1aac967 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -169,7 +169,15 @@ class DatabaseQuery: controller = get_controller(self.doctype) self.parse_args() - return controller.get_list(self.__dict__) + kwargs = { + "as_list": as_list, + "with_comment_count": with_comment_count, + "save_user_settings": save_user_settings, + "save_user_settings_fields": save_user_settings_fields, + "pluck": pluck, + "parent_doctype": parent_doctype, + } | self.__dict__ + return controller.get_list(kwargs) self.columns = self.get_table_columns() From b8ba7dcdb33ff0b25634670154ee0d7e373bf3ce Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 31 Jan 2023 17:52:35 +0530 Subject: [PATCH 222/407] fix: Setting default print format (#19862) - Remove check for developer mode, it's not even valid as we dont allow setting default print format like this - Set in custom doctype if custom doctype else prop setter. - query meta instead of doctype. [skip ci] --- .../doctype/print_format/print_format.js | 29 ++++++++++--------- .../doctype/print_format/print_format.py | 7 ++--- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/frappe/printing/doctype/print_format/print_format.js b/frappe/printing/doctype/print_format/print_format.js index 7b746241ac..dfe5633f65 100644 --- a/frappe/printing/doctype/print_format/print_format.js +++ b/frappe/printing/doctype/print_format/print_format.js @@ -40,20 +40,23 @@ frappe.ui.form.on("Print Format", { frm.set_df_property("html", "reqd", 1); } if (frappe.model.can_write("Customize Form")) { - frappe.db.get_value("DocType", frm.doc.doc_type, "default_print_format", (r) => { - if (r.default_print_format != frm.doc.name) { - frm.add_custom_button(__("Set as Default"), function () { - frappe.call({ - method: "frappe.printing.doctype.print_format.print_format.make_default", - args: { - name: frm.doc.name, - }, - callback: function () { - frm.refresh(); - }, - }); - }); + frappe.model.with_doctype(frm.doc.doc_type, function () { + let current_format = frappe.get_meta(frm.doc.DocType).default_print_format; + if (current_format == frm.doc.name) { + return; } + + frm.add_custom_button(__("Set as Default"), function () { + frappe.call({ + method: "frappe.printing.doctype.print_format.print_format.make_default", + args: { + name: frm.doc.name, + }, + callback: function () { + frm.refresh(); + }, + }); + }); }); } } diff --git a/frappe/printing/doctype/print_format/print_format.py b/frappe/printing/doctype/print_format/print_format.py index 9b1715f15f..599a16bee3 100644 --- a/frappe/printing/doctype/print_format/print_format.py +++ b/frappe/printing/doctype/print_format/print_format.py @@ -109,13 +109,12 @@ def make_default(name): print_format = frappe.get_doc("Print Format", name) - if (frappe.conf.get("developer_mode") or 0) == 1: - # developer mode, set it default in doctype - doctype = frappe.get_doc("DocType", print_format.doc_type) + doctype = frappe.get_doc("DocType", print_format.doc_type) + if doctype.custom: doctype.default_print_format = name doctype.save() else: - # customization + # "Customize form" frappe.make_property_setter( { "doctype_or_field": "DocType", From 4ca23dd5fa9df7c0c16dd756589d22c38651a39f Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 31 Jan 2023 18:52:13 +0530 Subject: [PATCH 223/407] fix: Dont setup socketio events on new doc (#19864) --- frappe/public/js/frappe/form/form.js | 4 +++- frappe/public/js/frappe/socketio_client.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 9aa7529761..001279f394 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -1934,7 +1934,9 @@ frappe.ui.form.Form = class FrappeForm { let doctype = this.doctype; let docname = this.docname; - frappe.socketio.doc_subscribe(doctype, docname); + if (this.doc && !this.is_new()) { + frappe.socketio.doc_subscribe(doctype, docname); + } frappe.realtime.off("docinfo_update"); frappe.realtime.on("docinfo_update", ({ doc, key, action = "update" }) => { if ( diff --git a/frappe/public/js/frappe/socketio_client.js b/frappe/public/js/frappe/socketio_client.js index 792346ed87..d4a26f3188 100644 --- a/frappe/public/js/frappe/socketio_client.js +++ b/frappe/public/js/frappe/socketio_client.js @@ -216,7 +216,7 @@ frappe.socketio = { } }); - if (cur_frm && cur_frm.doc) { + if (cur_frm && cur_frm.doc && !cur_frm.is_new()) { frappe.socketio.doc_open(cur_frm.doc.doctype, cur_frm.doc.name); } }, 5000); From 9d97097c12e6325a8ce7cdbe8a59e1193ceb75e4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 1 Feb 2023 10:22:50 +0530 Subject: [PATCH 224/407] fix: dont apply varchar length validation on singles (#19872) closes https://github.com/frappe/frappe/issues/19833 [skip ci] --- frappe/public/js/frappe/form/controls/data.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frappe/public/js/frappe/form/controls/data.js b/frappe/public/js/frappe/form/controls/data.js index 5917a85bdb..2d0f506917 100644 --- a/frappe/public/js/frappe/form/controls/data.js +++ b/frappe/public/js/frappe/form/controls/data.js @@ -220,6 +220,10 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlInp this.df.fieldtype ) ) { + if (this.frm?.meta?.issingle) { + // singles dont have any "real" length requirements + return; + } this.$input.attr("maxlength", this.df.length || 140); } From bcbcc87f4beee262ebf0dcb81a1ccdd6991cffb0 Mon Sep 17 00:00:00 2001 From: developsessions Date: Tue, 31 Jan 2023 23:16:13 +0100 Subject: [PATCH 225/407] fix: possible none value evaluation in get_formatted function --- frappe/model/base_document.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 783e879bff..d8d7cedd5a 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -1109,7 +1109,8 @@ class BaseDocument: df = get_default_df(fieldname) if ( - df.fieldtype == "Currency" + df + and df.fieldtype == "Currency" and not currency and (currency_field := df.get("options")) and (currency_value := self.get(currency_field)) From 967ea894d9209bb12b45057ec1a2a727d142adea Mon Sep 17 00:00:00 2001 From: sameer Chauhan Date: Wed, 1 Feb 2023 01:30:09 +0530 Subject: [PATCH 226/407] fix: translations update in MutliSelectDialog fix: translations update in MutliSelectDialog in the title field (cherry picked from commit 88c77edc0d33c29e2eadbb6dabab83ba46d27774) --- frappe/public/js/frappe/form/multi_select_dialog.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/form/multi_select_dialog.js b/frappe/public/js/frappe/form/multi_select_dialog.js index ee5e27c919..0c1c150c95 100644 --- a/frappe/public/js/frappe/form/multi_select_dialog.js +++ b/frappe/public/js/frappe/form/multi_select_dialog.js @@ -75,8 +75,8 @@ frappe.ui.form.MultiSelectDialog = class MultiSelectDialog { } make() { - let doctype_plural = this.doctype.plural(); - let title = __("Select {0}", [this.for_select ? __("value") : __(doctype_plural)]); + let doctype_plural = __(this.doctype).plural(); + let title = __("Select {0}", [this.for_select ? __("value") : doctype_plural]); this.dialog = new frappe.ui.Dialog({ title: title, From 1b016d34bdea102a8590d89133d58a43de46caf2 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 1 Feb 2023 16:02:59 +0530 Subject: [PATCH 227/407] fix: misc migration related fixes (#19874) * fix: ignore certain validations during migrate These are recoverable after migration is completed, better to let update go through first. * fix: Let people set arbitrary Data field options It will be treated as vanilla Data field by default but other apps can chose to modify behaviour based on it. AFAIK there is no real side effects of this. --- frappe/core/doctype/doctype/doctype.py | 49 ++++++++++----------- frappe/core/doctype/doctype/test_doctype.py | 26 ----------- 2 files changed, 24 insertions(+), 51 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py index 64b6f3123d..0ac5533ea2 100644 --- a/frappe/core/doctype/doctype/doctype.py +++ b/frappe/core/doctype/doctype/doctype.py @@ -983,7 +983,7 @@ def change_name_column_type(doctype_name: str, type: str) -> None: def validate_links_table_fieldnames(meta): """Validate fieldnames in Links table""" - if not meta.links or frappe.flags.in_patch or frappe.flags.in_fixtures: + if not meta.links or frappe.flags.in_patch or frappe.flags.in_fixtures or frappe.flags.in_migrate: return fieldnames = tuple(field.fieldname for field in meta.fields) @@ -1098,10 +1098,7 @@ def validate_fields(meta): ) def check_link_table_options(docname, d): - if frappe.flags.in_patch: - return - - if frappe.flags.in_fixtures: + if frappe.flags.in_patch or frappe.flags.in_fixtures: return if d.fieldtype in ("Link",) + table_fields: @@ -1418,10 +1415,9 @@ def validate_fields(meta): ) df_options_str = "
  • " + "
  • ".join(_(x) for x in data_field_options) + "
" - frappe.msgprint(text_str + df_options_str, title="Invalid Data Field", raise_exception=True) + frappe.msgprint(text_str + df_options_str, title="Invalid Data Field", alert=True) def check_child_table_option(docfield): - if frappe.flags.in_fixtures: return if docfield.fieldtype not in ["Table MultiSelect", "Table"]: @@ -1464,31 +1460,34 @@ def validate_fields(meta): check_invalid_fieldnames(meta.get("name"), d.fieldname) check_unique_fieldname(meta.get("name"), d.fieldname) check_fieldname_length(d.fieldname) - check_illegal_mandatory(meta.get("name"), d) - check_link_table_options(meta.get("name"), d) - check_dynamic_link_options(d) check_hidden_and_mandatory(meta.get("name"), d) - check_in_list_view(meta.get("istable"), d) - check_in_global_search(d) - check_illegal_default(d) check_unique_and_text(meta.get("name"), d) - check_illegal_depends_on_conditions(d) - check_child_table_option(d) check_table_multiselect_option(d) scrub_options_in_select(d) scrub_fetch_from(d) validate_data_field_type(d) - check_max_height(d) - check_no_of_ratings(d) - check_fold(fields) - check_search_fields(meta, fields) - check_title_field(meta) - check_timeline_field(meta) - check_is_published_field(meta) - check_website_search_field(meta) - check_sort_field(meta) - check_image_field(meta) + if not frappe.flags.in_migrate: + check_link_table_options(meta.get("name"), d) + check_illegal_mandatory(meta.get("name"), d) + check_dynamic_link_options(d) + check_in_list_view(meta.get("istable"), d) + check_in_global_search(d) + check_illegal_depends_on_conditions(d) + check_illegal_default(d) + check_child_table_option(d) + check_max_height(d) + check_no_of_ratings(d) + + if not frappe.flags.in_migrate: + check_fold(fields) + check_search_fields(meta, fields) + check_title_field(meta) + check_timeline_field(meta) + check_is_published_field(meta) + check_website_search_field(meta) + check_sort_field(meta) + check_image_field(meta) def get_fields_not_allowed_in_list_view(meta) -> list[str]: diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index e8226d4f9d..05ecc660e0 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -172,32 +172,6 @@ class TestDocType(FrappeTestCase): if condition: self.assertFalse(re.match(pattern, condition)) - def test_data_field_options(self): - doctype_name = "Test Data Fields" - valid_data_field_options = frappe.model.data_field_options + ("",) - invalid_data_field_options = ("Invalid Option 1", frappe.utils.random_string(5)) - - for field_option in valid_data_field_options + invalid_data_field_options: - test_doctype = frappe.get_doc( - { - "doctype": "DocType", - "name": doctype_name, - "module": "Core", - "custom": 1, - "fields": [ - {"fieldname": f"{field_option}_field", "fieldtype": "Data", "options": field_option} - ], - } - ) - - if field_option in invalid_data_field_options: - # assert that only data options in frappe.model.data_field_options are valid - self.assertRaises(frappe.ValidationError, test_doctype.insert) - else: - test_doctype.insert() - self.assertEqual(test_doctype.name, doctype_name) - test_doctype.delete() - def test_sync_field_order(self): import os From ccb7c8cd80dcac2542b2b70a49052008291b0a35 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 1 Feb 2023 17:05:01 +0530 Subject: [PATCH 228/407] fix(desk): Filter out other doctypes on list_update event --- frappe/public/js/frappe/list/list_view.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 6716999dcb..c80f64e6d9 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -1330,6 +1330,10 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { } frappe.socketio.doctype_subscribe(this.doctype); frappe.realtime.on("list_update", (data) => { + if (data?.doctype !== this.doctype) { + return; + } + if (!frappe.get_doc(data?.doctype, data?.name)?.__unsaved) { frappe.model.remove_from_locals(data.doctype, data.name); } @@ -1345,9 +1349,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { process_document_refreshes() { if (!this.pending_document_refreshes.length) return; - const names = this.pending_document_refreshes - .filter((d) => d.doctype === this.doctype) - .map((d) => d.name); + const names = this.pending_document_refreshes.map((d) => d.name); this.pending_document_refreshes = this.pending_document_refreshes.filter( (d) => names.indexOf(d.name) === -1 ); From f8d7151e199a486465c771e875fe48b80846ba0d Mon Sep 17 00:00:00 2001 From: phot0n Date: Wed, 1 Feb 2023 17:08:25 +0530 Subject: [PATCH 229/407] fix: use sender from formatted email body for email queue --- frappe/email/doctype/email_queue/email_queue.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py index 56f7f6f5ea..41740281a8 100644 --- a/frappe/email/doctype/email_queue/email_queue.py +++ b/frappe/email/doctype/email_queue/email_queue.py @@ -39,7 +39,7 @@ class EmailQueue(Document): def set_recipients(self, recipients): self.set("recipients", []) for r in recipients: - self.append("recipients", {"recipient": r, "status": "Not Sent"}) + self.append("recipients", {"recipient": r.strip(), "status": "Not Sent"}) def on_trash(self): self.prevent_email_queue_delete() @@ -711,7 +711,7 @@ class QueueBuilder: "attachments": json.dumps(self.get_attachments()), "message_id": get_string_between("<", mail.msg_root["Message-Id"], ">"), "message": mail_to_string, - "sender": self.sender, + "sender": mail.sender, "reference_doctype": self.reference_doctype, "reference_name": self.reference_name, "add_unsubscribe_link": self._add_unsubscribe_link, From cc7141edfe74eaac316723b4f137aa5cfe30099b Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 1 Feb 2023 19:09:01 +0530 Subject: [PATCH 230/407] fix: Use debounce to process_document_refreshes Co-authored-by: Suraj Shetty <13928957+surajshetty3416@users.noreply.github.com> --- frappe/public/js/frappe/list/list_view.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index c80f64e6d9..4e74710edf 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -1321,9 +1321,6 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { setup_realtime_updates() { this.pending_document_refreshes = []; - setInterval(() => { - this.process_document_refreshes(); - }, 1000); if (this.list_view_settings && this.list_view_settings.disable_auto_refresh) { return; @@ -1343,6 +1340,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { } this.pending_document_refreshes.push(data); + frappe.utils.debounce(this.process_document_refreshes.bind(this), 1000)(); }); } From 5236aeb4b0baa4436cea47a15a0616eb9eb16cf2 Mon Sep 17 00:00:00 2001 From: phot0n Date: Wed, 1 Feb 2023 20:12:02 +0530 Subject: [PATCH 231/407] test: email queue sender with always_use_account_name_as_sender_name and always_use_account_email_id_as_sender --- frappe/tests/test_email.py | 40 ++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/frappe/tests/test_email.py b/frappe/tests/test_email.py index de0fe00012..65910628db 100644 --- a/frappe/tests/test_email.py +++ b/frappe/tests/test_email.py @@ -3,9 +3,11 @@ import email import re +from unittest.mock import patch import frappe from frappe.email.doctype.email_account.test_email_account import TestEmailAccount +from frappe.email.doctype.email_queue.email_queue import QueueBuilder from frappe.tests.utils import FrappeTestCase test_dependencies = ["Email Account"] @@ -228,8 +230,37 @@ class TestEmail(FrappeTestCase): self.assertTrue("test1@example.com" in queue_recipients) self.assertEqual(len(queue_recipients), 2) + def test_sender(self): + def _patched_assertion(email_account, assertion): + with patch.object(QueueBuilder, "get_outgoing_email_account", return_value=email_account): + frappe.sendmail( + recipients=["test1@example.com"], + sender="admin@example.com", + subject="Test Email Queue", + message="This mail is queued!", + now=True, + ) + email_queue_sender = frappe.db.get_value("Email Queue", {"status": "Sent"}, "sender") + self.assertEqual(email_queue_sender, assertion) + + email_account = frappe.get_doc("Email Account", "_Test Email Account 1") + email_account.default_outgoing = 1 + + email_account.always_use_account_name_as_sender_name = 0 + email_account.always_use_account_email_id_as_sender = 0 + _patched_assertion(email_account, "admin@example.com") + + email_account.always_use_account_name_as_sender_name = 1 + _patched_assertion(email_account, "_Test Email Account 1 ") + + email_account.always_use_account_name_as_sender_name = 0 + email_account.always_use_account_email_id_as_sender = 1 + _patched_assertion(email_account, '"admin@example.com" ') + + email_account.always_use_account_name_as_sender_name = 1 + _patched_assertion(email_account, "_Test Email Account 1 ") + def test_unsubscribe(self): - from frappe.email.doctype.email_queue.email_queue import QueueBuilder from frappe.email.queue import unsubscribe unsubscribe(doctype="User", name="Administrator", email="test@example.com") @@ -322,10 +353,3 @@ class TestVerifiedRequests(FrappeTestCase): set_request(method="GET", path="?" + signed_url) self.assertTrue(verify_request()) frappe.local.request = None - - -if __name__ == "__main__": - import unittest - - frappe.connect() - unittest.main() From a0d1d1bd0ed80212fb1660d6e99c9bf84b26fe24 Mon Sep 17 00:00:00 2001 From: phot0n Date: Wed, 1 Feb 2023 21:37:23 +0530 Subject: [PATCH 232/407] test: fix test_unsubscribe --- frappe/email/queue.py | 2 +- frappe/tests/test_email.py | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/frappe/email/queue.py b/frappe/email/queue.py index c990226682..ea975b532b 100755 --- a/frappe/email/queue.py +++ b/frappe/email/queue.py @@ -99,7 +99,7 @@ def get_unsubcribed_url( @frappe.whitelist(allow_guest=True) def unsubscribe(doctype, name, email): # unsubsribe from comments and communications - if not verify_request(): + if not frappe.flags.in_test and not verify_request(): return try: diff --git a/frappe/tests/test_email.py b/frappe/tests/test_email.py index 65910628db..84785b70f9 100644 --- a/frappe/tests/test_email.py +++ b/frappe/tests/test_email.py @@ -264,7 +264,6 @@ class TestEmail(FrappeTestCase): from frappe.email.queue import unsubscribe unsubscribe(doctype="User", name="Administrator", email="test@example.com") - self.assertTrue( frappe.db.get_value( "Email Unsubscribe", @@ -272,10 +271,6 @@ class TestEmail(FrappeTestCase): ) ) - before = frappe.db.sql("""select count(name) from `tabEmail Queue` where status='Not Sent'""")[ - 0 - ][0] - builder = QueueBuilder( recipients=["test@example.com", "test1@example.com"], sender="admin@example.com", @@ -285,13 +280,11 @@ class TestEmail(FrappeTestCase): message="This is mail is queued!", unsubscribe_message="Unsubscribe", ) - builder.process() - # this is sent async (?) - email_queue = frappe.db.sql( - """select name from `tabEmail Queue` where status='Not Sent'""", as_dict=1 - ) - self.assertEqual(len(email_queue), before + 1) + # don't send right now + builder.process() + + email_queue = frappe.db.get_value("Email Queue", {"status": "Not Sent"}) queue_recipients = [ r.recipient for r in frappe.db.sql( @@ -303,6 +296,8 @@ class TestEmail(FrappeTestCase): self.assertFalse("test@example.com" in queue_recipients) self.assertTrue("test1@example.com" in queue_recipients) self.assertEqual(len(queue_recipients), 1) + + frappe.get_doc("Email Queue", email_queue).send() self.assertTrue("Unsubscribe" in frappe.safe_decode(frappe.flags.sent_mail)) def test_image_parsing(self): From dad0e5cbba8224f4c3b6d215685f1d55bc9f05b1 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 1 Feb 2023 22:37:43 +0530 Subject: [PATCH 233/407] fix: drop table if exists for action and links closes https://github.com/frappe/frappe/issues/19712 --- frappe/database/mariadb/framework_mariadb.sql | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/database/mariadb/framework_mariadb.sql b/frappe/database/mariadb/framework_mariadb.sql index efeeaaf935..9507a48b91 100644 --- a/frappe/database/mariadb/framework_mariadb.sql +++ b/frappe/database/mariadb/framework_mariadb.sql @@ -116,6 +116,7 @@ CREATE TABLE `tabDocPerm` ( -- Table structure for table `tabDocType Action` -- +DROP TABLE IF EXISTS `tabDocType Action`; CREATE TABLE `tabDocType Action` ( `name` varchar(140) COLLATE utf8mb4_unicode_ci NOT NULL, `creation` datetime(6) DEFAULT NULL, @@ -137,9 +138,10 @@ CREATE TABLE `tabDocType Action` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; -- --- Table structure for table `tabDocType Action` +-- Table structure for table `tabDocType Link` -- +DROP TABLE IF EXISTS `tabDocType Link`; CREATE TABLE `tabDocType Link` ( `name` varchar(140) COLLATE utf8mb4_unicode_ci NOT NULL, `creation` datetime(6) DEFAULT NULL, From d50f6fa7b47f0d5a72828242b8ee0e768793d79e Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 2 Feb 2023 13:42:29 +0530 Subject: [PATCH 234/407] test: cleanup test_create_virtual_doctype --- frappe/core/doctype/doctype/test_doctype.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index e8226d4f9d..25dcc03ce9 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -552,13 +552,14 @@ class TestDocType(FrappeTestCase): self.assertRaises(InvalidFieldNameError, validate_links_table_fieldnames, doc) def test_create_virtual_doctype(self): - """Test virtual DOcTYpe.""" + """Test virtual DocType.""" virtual_doc = new_doctype("Test Virtual Doctype") virtual_doc.is_virtual = 1 - virtual_doc.insert() - virtual_doc.save() + virtual_doc.insert(ignore_if_duplicate=True) + virtual_doc.reload() doc = frappe.get_doc("DocType", "Test Virtual Doctype") + self.assertDictEqual(doc.as_dict(), virtual_doc.as_dict()) self.assertEqual(doc.is_virtual, 1) self.assertFalse(frappe.db.table_exists("Test Virtual Doctype")) From 5d3453eeb9eaacaa761ccfa26af68adea0a99d88 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 2 Feb 2023 13:43:31 +0530 Subject: [PATCH 235/407] refactor: Re-use DefaultOrderBy value as global constant --- frappe/database/database.py | 7 ++++--- frappe/database/query.py | 4 ++-- frappe/database/utils.py | 2 +- frappe/model/db_query.py | 6 +++--- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/frappe/database/database.py b/frappe/database/database.py index fe258be8d7..5fd379b9c5 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -20,6 +20,7 @@ import frappe.defaults import frappe.model.meta from frappe import _ from frappe.database.utils import ( + DefaultOrderBy, EmptyQueryValues, FallBackDateTimeStr, LazyMogrify, @@ -422,7 +423,7 @@ class Database: ignore=None, as_dict=False, debug=False, - order_by="KEEP_DEFAULT_ORDERING", + order_by=DefaultOrderBy, cache=False, for_update=False, *, @@ -492,7 +493,7 @@ class Database: ignore=None, as_dict=False, debug=False, - order_by="KEEP_DEFAULT_ORDERING", + order_by=DefaultOrderBy, update=None, cache=False, for_update=False, @@ -551,7 +552,7 @@ class Database: if (filters is not None) and (filters != doctype or doctype == "DocType"): try: if order_by: - order_by = "modified" if order_by == "KEEP_DEFAULT_ORDERING" else order_by + order_by = "modified" if order_by == DefaultOrderBy else order_by out = self._get_values_from_table( fields=fields, filters=filters, diff --git a/frappe/database/query.py b/frappe/database/query.py index 10423f9ca4..3bf6824ab4 100644 --- a/frappe/database/query.py +++ b/frappe/database/query.py @@ -10,7 +10,7 @@ from pypika.queries import QueryBuilder, Table import frappe from frappe import _ from frappe.database.operator_map import OPERATOR_MAP -from frappe.database.utils import get_doctype_name +from frappe.database.utils import DefaultOrderBy, get_doctype_name from frappe.query_builder import Criterion, Field, Order, functions from frappe.query_builder.functions import Function, SqlFunctions from frappe.query_builder.utils import PseudoColumnMapper @@ -314,7 +314,7 @@ class Engine: return _fields def apply_order_by(self, order_by: str | None): - if not order_by or order_by == "KEEP_DEFAULT_ORDERING": + if not order_by or order_by == DefaultOrderBy: return for declaration in order_by.split(","): if _order_by := declaration.strip(): diff --git a/frappe/database/utils.py b/frappe/database/utils.py index 304fd72be6..d1030ca6d7 100644 --- a/frappe/database/utils.py +++ b/frappe/database/utils.py @@ -17,7 +17,7 @@ QueryValues = tuple | list | dict | NoneType EmptyQueryValues = object() FallBackDateTimeStr = "0001-01-01 00:00:00.000000" - +DefaultOrderBy = "KEEP_DEFAULT_ORDERING" NestedSetHierarchy = ( "ancestors of", "descendants of", diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index 23d1aac967..c9789ae9bc 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -13,7 +13,7 @@ import frappe.permissions import frappe.share from frappe import _ from frappe.core.doctype.server_script.server_script_utils import get_server_script_map -from frappe.database.utils import FallBackDateTimeStr, NestedSetHierarchy +from frappe.database.utils import DefaultOrderBy, FallBackDateTimeStr, NestedSetHierarchy from frappe.model import optional_fields from frappe.model.meta import get_table_columns from frappe.model.utils import is_virtual_doctype @@ -73,7 +73,7 @@ class DatabaseQuery: or_filters=None, docstatus=None, group_by=None, - order_by="KEEP_DEFAULT_ORDERING", + order_by=DefaultOrderBy, limit_start=False, limit_page_length=None, as_list=False, @@ -888,7 +888,7 @@ class DatabaseQuery: def set_order_by(self, args): meta = frappe.get_meta(self.doctype) - if self.order_by and self.order_by != "KEEP_DEFAULT_ORDERING": + if self.order_by and self.order_by != DefaultOrderBy: args.order_by = self.order_by else: args.order_by = "" From c4061904da70b1daeb6b5d5df61d3c0878c6ec33 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 2 Feb 2023 13:45:17 +0530 Subject: [PATCH 236/407] test: Split DBQuery & ReportView API tests into separate cases --- frappe/tests/test_db_query.py | 177 +++++++++++++++++----------------- 1 file changed, 90 insertions(+), 87 deletions(-) diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 1fa751662c..242b7eb02c 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -1,10 +1,13 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE import datetime +from unittest.mock import MagicMock, patch import frappe +from frappe.core.doctype.doctype.test_doctype import new_doctype from frappe.core.page.permission_manager.permission_manager import add, reset, update from frappe.custom.doctype.property_setter.property_setter import make_property_setter +from frappe.database.utils import DefaultOrderBy from frappe.desk.reportview import get_filters_cond from frappe.handler import execute_cmd from frappe.model.db_query import DatabaseQuery @@ -16,7 +19,7 @@ from frappe.utils.testutils import add_custom_field, clear_custom_fields test_dependencies = ["User", "Blog Post", "Blog Category", "Blogger"] -class TestReportview(FrappeTestCase): +class TestDBQuery(FrappeTestCase): def setUp(self): frappe.set_user("Administrator") @@ -726,89 +729,6 @@ class TestReportview(FrappeTestCase): self.assertEqual(users_unedited[0].modified, users_unedited[0].creation) self.assertNotEqual(users_edited[0].modified, users_edited[0].creation) - def test_reportview_get(self): - user = frappe.get_doc("User", "test@example.com") - add_child_table_to_blog_post() - - user_roles = frappe.get_roles() - user.remove_roles(*user_roles) - user.add_roles("Blogger") - - make_property_setter("Blog Post", "published", "permlevel", 1, "Int") - reset("Blog Post") - add("Blog Post", "Website Manager", 1) - update("Blog Post", "Website Manager", 1, "write", 1) - - frappe.set_user(user.name) - - frappe.local.request = frappe._dict() - frappe.local.request.method = "POST" - - frappe.local.form_dict = frappe._dict( - { - "doctype": "Blog Post", - "fields": ["published", "title", "`tabTest Child`.`test_field`"], - } - ) - - # even if * is passed, fields which are not accessible should be filtered out - response = execute_cmd("frappe.desk.reportview.get") - self.assertListEqual(response["keys"], ["title"]) - frappe.local.form_dict = frappe._dict( - { - "doctype": "Blog Post", - "fields": ["*"], - } - ) - - response = execute_cmd("frappe.desk.reportview.get") - self.assertNotIn("published", response["keys"]) - - frappe.set_user("Administrator") - user.add_roles("Website Manager") - frappe.set_user(user.name) - - frappe.set_user("Administrator") - - # Admin should be able to see access all fields - frappe.local.form_dict = frappe._dict( - { - "doctype": "Blog Post", - "fields": ["published", "title", "`tabTest Child`.`test_field`"], - } - ) - - response = execute_cmd("frappe.desk.reportview.get") - self.assertListEqual(response["keys"], ["published", "title", "test_field"]) - - # reset user roles - user.remove_roles("Blogger", "Website Manager") - user.add_roles(*user_roles) - - def test_reportview_get_aggregation(self): - # test aggregation based on child table field - frappe.local.form_dict = frappe._dict( - { - "doctype": "DocType", - "fields": """["`tabDocField`.`label` as field_label","`tabDocField`.`name` as field_name"]""", - "filters": "[]", - "order_by": "_aggregate_column desc", - "start": 0, - "page_length": 20, - "view": "Report", - "with_comment_count": 0, - "group_by": "field_label, field_name", - "aggregate_on_field": "columns", - "aggregate_on_doctype": "DocField", - "aggregate_function": "sum", - } - ) - - response = execute_cmd("frappe.desk.reportview.get") - self.assertListEqual( - response["keys"], ["field_label", "field_name", "_aggregate_column", "columns"] - ) - def test_cast_name(self): from frappe.core.doctype.doctype.test_doctype import new_doctype @@ -916,6 +836,91 @@ class TestReportview(FrappeTestCase): self.assertIn("ifnull", frappe.get_all("User", {"name": ("not in", [""])}, run=0)) +class TestReportView(FrappeTestCase): + def test_reportview_get(self): + user = frappe.get_doc("User", "test@example.com") + add_child_table_to_blog_post() + + user_roles = frappe.get_roles() + user.remove_roles(*user_roles) + user.add_roles("Blogger") + + make_property_setter("Blog Post", "published", "permlevel", 1, "Int") + reset("Blog Post") + add("Blog Post", "Website Manager", 1) + update("Blog Post", "Website Manager", 1, "write", 1) + + frappe.set_user(user.name) + + frappe.local.request = frappe._dict() + frappe.local.request.method = "POST" + + frappe.local.form_dict = frappe._dict( + { + "doctype": "Blog Post", + "fields": ["published", "title", "`tabTest Child`.`test_field`"], + } + ) + + # even if * is passed, fields which are not accessible should be filtered out + response = execute_cmd("frappe.desk.reportview.get") + self.assertListEqual(response["keys"], ["title"]) + frappe.local.form_dict = frappe._dict( + { + "doctype": "Blog Post", + "fields": ["*"], + } + ) + + response = execute_cmd("frappe.desk.reportview.get") + self.assertNotIn("published", response["keys"]) + + frappe.set_user("Administrator") + user.add_roles("Website Manager") + frappe.set_user(user.name) + + frappe.set_user("Administrator") + + # Admin should be able to see access all fields + frappe.local.form_dict = frappe._dict( + { + "doctype": "Blog Post", + "fields": ["published", "title", "`tabTest Child`.`test_field`"], + } + ) + + response = execute_cmd("frappe.desk.reportview.get") + self.assertListEqual(response["keys"], ["published", "title", "test_field"]) + + # reset user roles + user.remove_roles("Blogger", "Website Manager") + user.add_roles(*user_roles) + + def test_reportview_get_aggregation(self): + # test aggregation based on child table field + frappe.local.form_dict = frappe._dict( + { + "doctype": "DocType", + "fields": """["`tabDocField`.`label` as field_label","`tabDocField`.`name` as field_name"]""", + "filters": "[]", + "order_by": "_aggregate_column desc", + "start": 0, + "page_length": 20, + "view": "Report", + "with_comment_count": 0, + "group_by": "field_label, field_name", + "aggregate_on_field": "columns", + "aggregate_on_doctype": "DocField", + "aggregate_function": "sum", + } + ) + + response = execute_cmd("frappe.desk.reportview.get") + self.assertListEqual( + response["keys"], ["field_label", "field_name", "_aggregate_column", "columns"] + ) + + def add_child_table_to_blog_post(): child_table = frappe.get_doc( { @@ -939,7 +944,7 @@ def create_event(subject="_Test Event", starts_on=None): from frappe.utils import get_datetime - event = frappe.get_doc( + return frappe.get_doc( { "doctype": "Event", "subject": subject, @@ -948,8 +953,6 @@ def create_event(subject="_Test Event", starts_on=None): } ).insert(ignore_permissions=True) - return event - def create_nested_doctype(): if frappe.db.exists("DocType", "Nested DocType"): From fdff6351cda65aad0cef8a3ec08899c327ffe524 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 2 Feb 2023 13:45:35 +0530 Subject: [PATCH 237/407] test: Add test for DatabaseQuery for virtual doctypes --- frappe/tests/test_db_query.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 242b7eb02c..38bc469388 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -826,6 +826,33 @@ class TestDBQuery(FrappeTestCase): self.assertTrue(dashboard_settings) + def test_virtual_doctype(self): + """Test that virtual doctypes can be queried using get_all""" + + virtual_doctype = new_doctype("Virtual DocType") + virtual_doctype.is_virtual = 1 + virtual_doctype.insert(ignore_if_duplicate=True) + + class VirtualDocType: + @staticmethod + def get_list(args): + ... + + with patch("frappe.controllers", new={frappe.local.site: {"Virtual DocType": VirtualDocType}}): + VirtualDocType.get_list = MagicMock() + + frappe.get_all("Virtual DocType", filters={"name": "test"}, fields=["name"], limit=1) + + call_args = VirtualDocType.get_list.call_args[0][0] + VirtualDocType.get_list.assert_called_once() + self.assertIsInstance(call_args, dict) + self.assertEqual(call_args["doctype"], "Virtual DocType") + self.assertEqual(call_args["filters"], [["Virtual DocType", "name", "=", "test"]]) + self.assertEqual(call_args["fields"], ["name"]) + self.assertEqual(call_args["limit_page_length"], 1) + self.assertEqual(call_args["limit_start"], 0) + self.assertEqual(call_args["order_by"], DefaultOrderBy) + def test_coalesce_with_in_ops(self): self.assertNotIn("ifnull", frappe.get_all("User", {"name": ("in", ["a", "b"])}, run=0)) self.assertIn("ifnull", frappe.get_all("User", {"name": ("in", ["a", None])}, run=0)) From 07529ff1c3ad4aeb354c305483bc293cda2b72ce Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 2 Feb 2023 17:05:44 +0530 Subject: [PATCH 238/407] fix: Consider parenttype when renaming (#19901) --- frappe/model/rename_doc.py | 6 +++++- frappe/tests/test_rename_doc.py | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/frappe/model/rename_doc.py b/frappe/model/rename_doc.py index 68b03dde55..420cbee091 100644 --- a/frappe/model/rename_doc.py +++ b/frappe/model/rename_doc.py @@ -397,7 +397,11 @@ def rename_doctype(doctype: str, old: str, new: str) -> None: def update_child_docs(old: str, new: str, meta: "Meta") -> None: # update "parent" for df in meta.get_table_fields(): - frappe.qb.update(df.options).set("parent", new).where(Field("parent") == old).run() + ( + frappe.qb.update(df.options) + .set("parent", new) + .where((Field("parent") == old) & (Field("parenttype") == meta.name)) + ).run() def update_link_field_values(link_fields: list[dict], old: str, new: str, doctype: str) -> None: diff --git a/frappe/tests/test_rename_doc.py b/frappe/tests/test_rename_doc.py index d85efa79bb..e48f908147 100644 --- a/frappe/tests/test_rename_doc.py +++ b/frappe/tests/test_rename_doc.py @@ -8,6 +8,7 @@ from random import choice, sample from unittest.mock import patch import frappe +from frappe.core.doctype.doctype.test_doctype import new_doctype from frappe.exceptions import DoesNotExistError, ValidationError from frappe.model.base_document import get_controller from frappe.model.rename_doc import ( @@ -271,3 +272,29 @@ class TestRenameDoc(FrappeTestCase): self.assertEqual(doc.name, new_name) self.available_documents.append(new_name) self.available_documents.remove(name) + + def test_parenttype(self): + child = new_doctype(istable=1).insert() + table_field = { + "label": "Test Table", + "fieldname": "test_table", + "fieldtype": "Table", + "options": child.name, + } + + parent_a = new_doctype(fields=[table_field], allow_rename=1, autoname="Prompt").insert() + parent_b = new_doctype(fields=[table_field], allow_rename=1, autoname="Prompt").insert() + + parent_a_instance = frappe.get_doc( + doctype=parent_a.name, test_table=[{"some_fieldname": "x"}], name="XYZ" + ).insert() + + parent_b_instance = frappe.get_doc( + doctype=parent_b.name, test_table=[{"some_fieldname": "x"}], name="XYZ" + ).insert() + + parent_b_instance.rename("ABC") + parent_a_instance.reload() + + self.assertEqual(len(parent_a_instance.test_table), 1) + self.assertEqual(len(parent_b_instance.test_table), 1) From 3f1deeba67cbf2f4adcb9dea32f2c4f8262cfb6e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 2 Feb 2023 22:53:24 +0530 Subject: [PATCH 239/407] fix: can't sign out due to missing roles (#19905) --- frappe/__init__.py | 2 +- frappe/permissions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/__init__.py b/frappe/__init__.py index e32b04dccb..f7208035e5 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -570,7 +570,7 @@ def get_user(): def get_roles(username=None) -> list[str]: """Returns roles of current user.""" - if not local.session: + if not local.session or not local.session.user: return ["Guest"] import frappe.permissions diff --git a/frappe/permissions.py b/frappe/permissions.py index 2bee75d50c..91517e774f 100644 --- a/frappe/permissions.py +++ b/frappe/permissions.py @@ -413,7 +413,7 @@ def get_roles(user=None, with_standard=True): if not user: user = frappe.session.user - if user == "Guest": + if user == "Guest" or not user: return ["Guest"] def get(): From 47edc6317007203d4bf1b4422e34c3fb7d586fa8 Mon Sep 17 00:00:00 2001 From: Ritwik Puri Date: Fri, 3 Feb 2023 11:47:38 +0530 Subject: [PATCH 240/407] fix: support for different delimiter for timeline email linking (#19751) --- .../doctype/communication/communication.py | 28 +++++++++++-------- .../communication/test_communication.py | 23 +++++++-------- frappe/desk/form/load.py | 2 +- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/frappe/core/doctype/communication/communication.py b/frappe/core/doctype/communication/communication.py index 9756bc73c0..6b948947a8 100644 --- a/frappe/core/doctype/communication/communication.py +++ b/frappe/core/doctype/communication/communication.py @@ -487,28 +487,32 @@ def parse_email(communication, email_strings): """ Parse email to add timeline links. When automatic email linking is enabled, an email from email_strings can contain - a doctype and docname ie in the format `admin+doctype+docname@example.com`, + a doctype and docname ie in the format `admin+doctype+docname@example.com` or `admin+doctype=docname@example.com`, the email is parsed and doctype and docname is extracted and timeline link is added. """ - if not frappe.get_all("Email Account", filters={"enable_automatic_linking": 1}): + if not frappe.db.get_value("Email Account", filters={"enable_automatic_linking": 1}): return - delimiter = "+" - for email_string in email_strings: if email_string: for email in email_string.split(","): - if delimiter in email: - email = email.split("@", 1)[0] - email_local_parts = email.split(delimiter) - if not len(email_local_parts) == 3: - continue - + email_username = email.split("@", 1)[0] + email_local_parts = email_username.split("+") + docname = doctype = None + if len(email_local_parts) == 3: doctype = unquote(email_local_parts[1]) docname = unquote(email_local_parts[2]) - if doctype and docname and frappe.db.exists(doctype, docname): - communication.add_link(doctype, docname) + elif len(email_local_parts) == 2: + document_parts = email_local_parts[1].split("=", 1) + if len(document_parts) != 2: + continue + + doctype = unquote(document_parts[0]) + docname = unquote(document_parts[1]) + + if doctype and docname and frappe.db.get_value(doctype, docname, ignore=True): + communication.add_link(doctype, docname) def get_email_without_link(email): diff --git a/frappe/core/doctype/communication/test_communication.py b/frappe/core/doctype/communication/test_communication.py index 5b208eaeb7..04e57f10cf 100644 --- a/frappe/core/doctype/communication/test_communication.py +++ b/frappe/core/doctype/communication/test_communication.py @@ -219,17 +219,17 @@ class TestCommunication(FrappeTestCase): self.assertIn(comm_note_2.name, data) def test_link_in_email(self): - frappe.delete_doc_if_exists("Note", "test document link in email") - create_email_account() - note = frappe.get_doc( - { - "doctype": "Note", - "title": "test document link in email", - "content": "test document link in email", - } - ).insert(ignore_permissions=True) + notes = {} + for i in range(2): + frappe.delete_doc_if_exists("Note", f"test document link in email {i}") + notes[i] = frappe.get_doc( + { + "doctype": "Note", + "title": f"test document link in email {i}", + } + ).insert(ignore_permissions=True) comm = frappe.get_doc( { @@ -237,14 +237,15 @@ class TestCommunication(FrappeTestCase): "communication_medium": "Email", "subject": "Document Link in Email", "sender": "comm_sender@example.com", - "recipients": f'comm_recipient+{quote("Note")}+{quote(note.name)}@example.com', + "recipients": f'comm_recipient+{quote("Note")}+{quote(notes[0].name)}@example.com,comm_recipient+{quote("Note")}={quote(notes[1].name)}@example.com', } ).insert(ignore_permissions=True) doc_links = [ (timeline_link.link_doctype, timeline_link.link_name) for timeline_link in comm.timeline_links ] - self.assertIn(("Note", note.name), doc_links) + self.assertIn(("Note", notes[0].name), doc_links) + self.assertIn(("Note", notes[1].name), doc_links) def test_parse_emails(self): emails = get_emails( diff --git a/frappe/desk/form/load.py b/frappe/desk/form/load.py index 3627f48109..42109f8863 100644 --- a/frappe/desk/form/load.py +++ b/frappe/desk/form/load.py @@ -403,7 +403,7 @@ def get_document_email(doctype, name): return None email = email.split("@") - return f"{email[0]}+{quote(doctype)}+{quote(cstr(name))}@{email[1]}" + return f"{email[0]}+{quote(doctype)}={quote(cstr(name))}@{email[1]}" def get_automatic_email_link(): From 5348dd1f2931abefa0fb65b1e63b8b609b32fd4f Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 3 Feb 2023 11:11:39 +0530 Subject: [PATCH 241/407] fix: Migration fails while inserting docfield When migrating base doctypes we need to insert docfield which triggers document naming rule code and document naming rule doesn't yet exists cause that's what we are trying to migrate. Fix: skip naming rule on bootstrapped doctypes. --- frappe/model/naming.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/model/naming.py b/frappe/model/naming.py index 29831451b0..d3b6fc7293 100644 --- a/frappe/model/naming.py +++ b/frappe/model/naming.py @@ -232,7 +232,11 @@ def set_naming_from_document_naming_rule(doc): """ Evaluate rules based on "Document Naming Series" doctype """ - if doc.doctype in log_types: + from frappe.model.base_document import DOCTYPES_FOR_DOCTYPE + + IGNORED_DOCTYPES = {*log_types, *DOCTYPES_FOR_DOCTYPE, "DefaultValue", "Patch Log"} + + if doc.doctype in IGNORED_DOCTYPES: return document_naming_rules = frappe.cache_manager.get_doctype_map( From 8d133ce32accfbc600d312484fbeae80029a0e2a Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 3 Feb 2023 12:40:24 +0530 Subject: [PATCH 242/407] fix: Ignore route conflict validations during migrate --- frappe/desk/utils.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/frappe/desk/utils.py b/frappe/desk/utils.py index 428ed95c02..77edf88d7a 100644 --- a/frappe/desk/utils.py +++ b/frappe/desk/utils.py @@ -9,16 +9,14 @@ def validate_route_conflict(doctype, name): Raises exception if name clashes with routes from other documents for /app routing """ + if frappe.flags.in_migrate: + return + all_names = [] for _doctype in ["Page", "Workspace", "DocType"]: - try: - all_names.extend( - [ - slug(d) for d in frappe.get_all(_doctype, pluck="name") if (doctype != _doctype and d != name) - ] - ) - except frappe.db.TableMissingError: - pass + all_names.extend( + [slug(d) for d in frappe.get_all(_doctype, pluck="name") if (doctype != _doctype and d != name)] + ) if slug(name) in all_names: frappe.msgprint(frappe._("Name already taken, please set a new name")) From 90c4543065f86f11b814d39d6e2c999bf4c7ae5a Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 3 Feb 2023 12:50:35 +0530 Subject: [PATCH 243/407] fix: Dont use cached controllers during migration --- frappe/model/base_document.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 2c9963ad23..43a1ac8d13 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -43,7 +43,7 @@ def get_controller(doctype): :param doctype: DocType name as string. """ - if frappe.local.dev_server: + if frappe.local.dev_server or frappe.flags.in_migrate: return import_controller(doctype) site_controllers = frappe.controllers.setdefault(frappe.local.site, {}) @@ -59,7 +59,7 @@ def import_controller(doctype): module_name = "Core" if doctype not in DOCTYPES_FOR_DOCTYPE: - meta = frappe.get_meta(doctype) + meta = frappe.get_meta(doctype, cached=not frappe.flags.in_migrate) if meta.custom: return NestedSet if meta.get("is_tree") else Document From b889bb5b5a41a7c8a49293eb3058159d68743d98 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 3 Feb 2023 13:06:09 +0530 Subject: [PATCH 244/407] fix: list view setting patch failures - make idempotent - ignore ordering (fails as it tries to query order which might not exist --- ...list_view_setting_to_list_view_settings.py | 39 +++++++++++-------- 1 file changed, 22 insertions(+), 17 deletions(-) diff --git a/frappe/patches/v13_0/rename_list_view_setting_to_list_view_settings.py b/frappe/patches/v13_0/rename_list_view_setting_to_list_view_settings.py index 2147a2da94..c7c8cbc724 100644 --- a/frappe/patches/v13_0/rename_list_view_setting_to_list_view_settings.py +++ b/frappe/patches/v13_0/rename_list_view_setting_to_list_view_settings.py @@ -5,22 +5,27 @@ import frappe def execute(): - if frappe.db.table_exists("List View Setting"): - if not frappe.db.table_exists("List View Settings"): - frappe.reload_doc("desk", "doctype", "List View Settings") + if not frappe.db.table_exists("List View Setting"): + return + if not frappe.db.exists("DocType", "List View Setting"): + return - existing_list_view_settings = frappe.get_all("List View Settings", as_list=True) - for list_view_setting in frappe.get_all( - "List View Setting", - fields=["disable_count", "disable_sidebar_stats", "disable_auto_refresh", "name"], - ): - name = list_view_setting.pop("name") - if name not in [x[0] for x in existing_list_view_settings]: - list_view_setting["doctype"] = "List View Settings" - list_view_settings = frappe.get_doc(list_view_setting) - # setting name here is necessary because autoname is set as prompt - list_view_settings.name = name - list_view_settings.insert() + frappe.reload_doc("desk", "doctype", "List View Settings") - frappe.delete_doc("DocType", "List View Setting", force=True) - frappe.db.commit() + existing_list_view_settings = frappe.get_all( + "List View Settings", as_list=True, order_by="modified" + ) + for list_view_setting in frappe.get_all( + "List View Setting", + fields=["disable_count", "disable_sidebar_stats", "disable_auto_refresh", "name"], + order_by="modified", + ): + name = list_view_setting.pop("name") + if name not in [x[0] for x in existing_list_view_settings]: + list_view_setting["doctype"] = "List View Settings" + list_view_settings = frappe.get_doc(list_view_setting) + # setting name here is necessary because autoname is set as prompt + list_view_settings.name = name + list_view_settings.insert() + + frappe.delete_doc("DocType", "List View Setting", force=True) From 75d092ef7dfcca82b6e2653e5118329acb94c061 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 3 Feb 2023 15:13:18 +0530 Subject: [PATCH 245/407] Revert "fix: Report sidebar must consider Permission Query" (#19921) --- frappe/boot.py | 26 +++++----------------- frappe/tests/test_boot.py | 46 +-------------------------------------- 2 files changed, 6 insertions(+), 66 deletions(-) diff --git a/frappe/boot.py b/frappe/boot.py index de3753f754..31e101aedc 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -3,16 +3,15 @@ """ bootstrap client session """ + import frappe import frappe.defaults import frappe.desk.desk_page from frappe.core.doctype.navbar_settings.navbar_settings import get_app_logo, get_navbar_settings -from frappe.database.utils import Query from frappe.desk.doctype.route_history.route_history import frequently_visited_links from frappe.desk.form.load import get_meta_bundle from frappe.email.inbox import get_email_accounts from frappe.model.base_document import get_controller -from frappe.model.db_query import DatabaseQuery from frappe.query_builder import DocType from frappe.query_builder.functions import Count from frappe.query_builder.terms import ParameterizedValueWrapper, SubQuery @@ -170,7 +169,6 @@ def get_user_pages_or_reports(parent, cache=False): parentTable = DocType(parent) # get pages or reports set on custom role - # must end in a WHERE clause for `_run_with_permission_query` pages_with_custom_roles = ( frappe.qb.from_(customRole) .from_(hasRole) @@ -184,8 +182,7 @@ def get_user_pages_or_reports(parent, cache=False): & (customRole[parent.lower()].isnotnull()) & (hasRole.role.isin(roles)) ) - ) - pages_with_custom_roles = _run_with_permission_query(pages_with_custom_roles, parent) + ).run(as_dict=True) for p in pages_with_custom_roles: has_role[p.name] = {"modified": p.modified, "title": p.title, "ref_doctype": p.ref_doctype} @@ -196,7 +193,6 @@ def get_user_pages_or_reports(parent, cache=False): .where(customRole[parent.lower()].isnotnull()) ) - # must end in a WHERE clause for `_run_with_permission_query` pages_with_standard_roles = ( frappe.qb.from_(hasRole) .from_(parentTable) @@ -212,7 +208,7 @@ def get_user_pages_or_reports(parent, cache=False): if parent == "Report": pages_with_standard_roles = pages_with_standard_roles.where(report.disabled == 0) - pages_with_standard_roles = _run_with_permission_query(pages_with_standard_roles, parent) + pages_with_standard_roles = pages_with_standard_roles.run(as_dict=True) for p in pages_with_standard_roles: if p.name not in has_role: @@ -226,13 +222,12 @@ def get_user_pages_or_reports(parent, cache=False): # pages with no role are allowed if parent == "Page": - # must end in a WHERE clause for `_run_with_permission_query` + pages_with_no_roles = ( frappe.qb.from_(parentTable) .select(parentTable.name, parentTable.modified, *columns) .where(no_of_roles == 0) - ) - pages_with_no_roles = _run_with_permission_query(pages_with_no_roles, parent) + ).run(as_dict=True) for p in pages_with_no_roles: if p.name not in has_role: @@ -253,17 +248,6 @@ def get_user_pages_or_reports(parent, cache=False): return has_role -def _run_with_permission_query(query: "Query", doctype: str) -> list[dict]: - """ - Adds Permission Query (Server Script) conditions and runs/executes modified query - Note: Works only if 'WHERE' is the last clause in the query - """ - permission_query = DatabaseQuery(doctype, frappe.session.user).get_permission_query_conditions() - if permission_query and frappe.session.user != "Administrator": - return frappe.db.sql(f"{query} AND {permission_query}", as_dict=True) - return query.run(as_dict=True) - - def load_translations(bootinfo): bootinfo["lang"] = frappe.lang bootinfo["__messages"] = get_messages_for_boot() diff --git a/frappe/tests/test_boot.py b/frappe/tests/test_boot.py index 232c379e08..0b688d6aee 100644 --- a/frappe/tests/test_boot.py +++ b/frappe/tests/test_boot.py @@ -1,5 +1,5 @@ import frappe -from frappe.boot import get_unseen_notes, get_user_pages_or_reports +from frappe.boot import get_unseen_notes from frappe.desk.doctype.note.note import mark_as_seen from frappe.tests.utils import FrappeTestCase @@ -26,47 +26,3 @@ class TestBootData(FrappeTestCase): mark_as_seen(note.name) unseen_notes = [d.title for d in get_unseen_notes()] self.assertListEqual(unseen_notes, []) - - def test_get_user_pages_or_reports_with_permission_query(self): - # Create a ToDo custom report with admin user - frappe.set_user("Administrator") - frappe.get_doc( - { - "doctype": "Report", - "ref_doctype": "ToDo", - "report_name": "Test Admin Report", - "report_type": "Report Builder", - "is_standard": "No", - } - ).insert() - - # Add permission query such that each user can only see their own custom reports - frappe.get_doc( - dict( - doctype="Server Script", - name="test_report_permission_query", - script_type="Permission Query", - reference_doctype="Report", - script="""conditions = f"(`tabReport`.is_standard = 'Yes' or `tabReport`.owner = '{frappe.session.user}')" - """, - ) - ).insert() - - # Create a ToDo custom report with test user - frappe.set_user("test@example.com") - frappe.get_doc( - { - "doctype": "Report", - "ref_doctype": "ToDo", - "report_name": "Test User Report", - "report_type": "Report Builder", - "is_standard": "No", - } - ).insert(ignore_permissions=True) - - get_user_pages_or_reports("Report") - allowed_reports = frappe.cache().get_value("has_role:Report", user=frappe.session.user) - - # Test user must not see admin user's report - self.assertNotIn("Test Admin Report", allowed_reports) - self.assertIn("Test User Report", allowed_reports) From 11f7e41994e9d2fd0384337222cb383c408c88ea Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 3 Feb 2023 13:23:45 +0530 Subject: [PATCH 246/407] fix: Show local variables in tracebacks during migration --- frappe/commands/site.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index fbbdde8e03..c78bd4a7c5 100644 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -592,6 +592,8 @@ def disable_user(context, email): @pass_context def migrate(context, skip_failing=False, skip_search_index=False): "Run patches, sync schema and rebuild files/translations" + from traceback_with_variables import activate_by_import + from frappe.migrate import SiteMigration for site in context.sites: From 95ad5c76965d5294d3be36e140f6ad65fa56ef06 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 3 Feb 2023 13:01:10 +0530 Subject: [PATCH 247/407] fix: Dont use meta for get_controller --- frappe/model/base_document.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 43a1ac8d13..e3694d1baf 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -59,11 +59,11 @@ def import_controller(doctype): module_name = "Core" if doctype not in DOCTYPES_FOR_DOCTYPE: - meta = frappe.get_meta(doctype, cached=not frappe.flags.in_migrate) - if meta.custom: - return NestedSet if meta.get("is_tree") else Document - - module_name = meta.module + doctype_info = frappe.db.get_value("DocType", doctype, fieldname="*") + if doctype_info: + if doctype_info.custom: + return NestedSet if doctype_info.is_tree else Document + module_name = doctype_info.module module_path = None class_overrides = frappe.get_hooks("override_doctype_class") From 6dcf12d5096435528aaa48527cf2de47aa007df1 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 3 Feb 2023 18:07:21 +0530 Subject: [PATCH 248/407] fix: Apply permissions on Report sidebar Alternate to https://github.com/frappe/frappe/pull/19588 Co-Authored-By: marination --- frappe/boot.py | 6 ++++- frappe/tests/test_boot.py | 46 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/frappe/boot.py b/frappe/boot.py index 31e101aedc..c56b30a3cb 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -234,7 +234,7 @@ def get_user_pages_or_reports(parent, cache=False): has_role[p.name] = {"modified": p.modified, "title": p.title} elif parent == "Report": - reports = frappe.get_all( + reports = frappe.get_list( "Report", fields=["name", "report_type"], filters={"name": ("in", has_role.keys())}, @@ -243,6 +243,10 @@ def get_user_pages_or_reports(parent, cache=False): for report in reports: has_role[report.name]["report_type"] = report.report_type + non_permitted_reports = set(has_role.keys()) - {r.name for r in reports} + for r in non_permitted_reports: + has_role.pop(r, None) + # Expire every six hours _cache.set_value("has_role:" + parent, has_role, frappe.session.user, 21600) return has_role diff --git a/frappe/tests/test_boot.py b/frappe/tests/test_boot.py index 0b688d6aee..232c379e08 100644 --- a/frappe/tests/test_boot.py +++ b/frappe/tests/test_boot.py @@ -1,5 +1,5 @@ import frappe -from frappe.boot import get_unseen_notes +from frappe.boot import get_unseen_notes, get_user_pages_or_reports from frappe.desk.doctype.note.note import mark_as_seen from frappe.tests.utils import FrappeTestCase @@ -26,3 +26,47 @@ class TestBootData(FrappeTestCase): mark_as_seen(note.name) unseen_notes = [d.title for d in get_unseen_notes()] self.assertListEqual(unseen_notes, []) + + def test_get_user_pages_or_reports_with_permission_query(self): + # Create a ToDo custom report with admin user + frappe.set_user("Administrator") + frappe.get_doc( + { + "doctype": "Report", + "ref_doctype": "ToDo", + "report_name": "Test Admin Report", + "report_type": "Report Builder", + "is_standard": "No", + } + ).insert() + + # Add permission query such that each user can only see their own custom reports + frappe.get_doc( + dict( + doctype="Server Script", + name="test_report_permission_query", + script_type="Permission Query", + reference_doctype="Report", + script="""conditions = f"(`tabReport`.is_standard = 'Yes' or `tabReport`.owner = '{frappe.session.user}')" + """, + ) + ).insert() + + # Create a ToDo custom report with test user + frappe.set_user("test@example.com") + frappe.get_doc( + { + "doctype": "Report", + "ref_doctype": "ToDo", + "report_name": "Test User Report", + "report_type": "Report Builder", + "is_standard": "No", + } + ).insert(ignore_permissions=True) + + get_user_pages_or_reports("Report") + allowed_reports = frappe.cache().get_value("has_role:Report", user=frappe.session.user) + + # Test user must not see admin user's report + self.assertNotIn("Test Admin Report", allowed_reports) + self.assertIn("Test User Report", allowed_reports) From 0482530ffd3813b9cfb2b7ecb40d1c5b2ed181ca Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Fri, 3 Feb 2023 20:04:11 +0530 Subject: [PATCH 249/407] fix: do not allow restricted fieldnames for custom fields --- .../custom/doctype/custom_field/custom_field.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py index 758d9c1e64..8953153be6 100644 --- a/frappe/custom/doctype/custom_field/custom_field.py +++ b/frappe/custom/doctype/custom_field/custom_field.py @@ -18,6 +18,18 @@ class CustomField(Document): self.name = self.dt + "-" + self.fieldname def set_fieldname(self): + restricted = ( + "name", + "parent", + "creation", + "modified", + "modified_by", + "parentfield", + "parenttype", + "file_list", + "flags", + "docstatus", + ) if not self.fieldname: label = self.label if not label: @@ -34,6 +46,9 @@ class CustomField(Document): # fieldnames should be lowercase self.fieldname = self.fieldname.lower() + if self.fieldname in restricted: + self.fieldname = self.fieldname + "1" + def before_insert(self): self.set_fieldname() From 0c17d400c0fc5b3d63f5517bf8bab0f825121982 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 3 Feb 2023 20:10:28 +0530 Subject: [PATCH 250/407] fix(db_query): Handle distinct in fn calls --- frappe/model/db_query.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index e3858a3ff7..b419e1366a 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -583,8 +583,12 @@ class DatabaseQuery: if "distinct" in field.lower(): # field: 'count(distinct `tabPhoto`.name) as total_count' # column: 'tabPhoto.name' - self.distinct = True - column = field.split(" ", 2)[1].replace("`", "").replace(")", "") + if _fn := FN_PARAMS_PATTERN.findall(field): + column = _fn[0].replace("distinct ", "").replace("DISTINCT ", "").replace("`", "") + # field: 'distinct name' + # column: 'name' + else: + column = field.split(" ", 2)[1].replace("`", "") else: # field: 'count(`tabPhoto`.name) as total_count' # column: 'tabPhoto.name' From d118cfb94a6ebcb1f35eb56e2e4196445884455e Mon Sep 17 00:00:00 2001 From: Ritwik Puri Date: Fri, 3 Feb 2023 20:20:00 +0530 Subject: [PATCH 251/407] fix: Pass parent_doctype to fetch permitted fields in child tables --- frappe/model/db_query.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index b419e1366a..9580bf7e56 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -163,6 +163,7 @@ class DatabaseQuery: self.run = run self.strict = strict self.ignore_ddl = ignore_ddl + self.parent_doctype = parent_doctype # for contextual user permission check # to determine which user permission is applicable on link field of specific doctype @@ -577,7 +578,7 @@ class DatabaseQuery: return asterisk_fields = [] - permitted_fields = get_permitted_fields(doctype=self.doctype) + permitted_fields = get_permitted_fields(doctype=self.doctype, parenttype=self.parent_doctype) for i, field in enumerate(self.fields): if "distinct" in field.lower(): From c4544cb37e19915e4d49ead34d75813ab50bffaf Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 3 Feb 2023 20:53:53 +0530 Subject: [PATCH 252/407] test: test_get_count --- frappe/tests/test_db_query.py | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 96b71f5eb7..2a3c1b6685 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -986,6 +986,46 @@ class TestDBQuery(FrappeTestCase): class TestReportView(FrappeTestCase): + def test_get_count(self): + frappe.local.request = frappe._dict() + frappe.local.request.method = "GET" + + # test with data check field + frappe.local.form_dict = frappe._dict( + { + "doctype": "DocType", + "filters": [["DocType", "show_title_field_in_link", "=", 1]], + "fields": [], + "distinct": "false", + } + ) + list_filter_response = execute_cmd("frappe.desk.reportview.get_count") + frappe.local.form_dict = frappe._dict( + {"doctype": "DocType", "filters": {"show_title_field_in_link": 1}, "distinct": "true"} + ) + dict_filter_response = execute_cmd("frappe.desk.reportview.get_count") + self.assertIsInstance(list_filter_response, int) + self.assertEqual(list_filter_response, dict_filter_response) + + # test with child table filter + frappe.local.form_dict = frappe._dict( + { + "doctype": "DocType", + "filters": [["DocField", "fieldtype", "=", "Data"]], + "fields": [], + "distinct": "true", + } + ) + child_filter_response = execute_cmd("frappe.desk.reportview.get_count") + current_value = frappe.db.sql( + # the below query is equivalent to the one in reportview.get_count + "select distinct count(distinct `tabDocType`.name) as total_count" + " from `tabDocType` left join `tabDocField`" + " on (`tabDocField`.parenttype = 'DocType' and `tabDocField`.parent = `tabDocType`.name)" + " where `tabDocField`.`fieldtype` = 'Data'" + )[0][0] + self.assertEqual(child_filter_response, current_value) + def test_reportview_get(self): user = frappe.get_doc("User", "test@example.com") add_child_table_to_blog_post() From e3d9c210a82dd4f05df50e5115a7419b9cc4b50a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 3 Feb 2023 21:25:56 +0530 Subject: [PATCH 253/407] test: Added test for get_permitted_fields --- frappe/tests/test_model_utils.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/frappe/tests/test_model_utils.py b/frappe/tests/test_model_utils.py index 0baef8bb85..e8d62a48cd 100644 --- a/frappe/tests/test_model_utils.py +++ b/frappe/tests/test_model_utils.py @@ -1,4 +1,8 @@ +from contextlib import contextmanager +from random import choice + import frappe +from frappe.model import core_doctypes_list, get_permitted_fields from frappe.model.utils import get_fetch_values from frappe.tests.utils import FrappeTestCase @@ -25,3 +29,30 @@ class TestModelUtils(FrappeTestCase): self.assertEqual( get_fetch_values(doctype, "assigned_by", user), {"assigned_by_full_name": full_name} ) + + def test_get_permitted_fields(self): + # Administrator should have access to all fields in ToDo + todo_all_fields = get_permitted_fields("ToDo", user="Administrator") + todo_all_columns = frappe.get_meta("ToDo").get_valid_columns() + self.assertListEqual(todo_all_fields, todo_all_columns) + + # Guest should have access to only default fields in ToDo + with set_user("Guest"): + guest_permitted_fields = get_permitted_fields("ToDo") + self.assertSequenceSubset(todo_all_fields, guest_permitted_fields) + self.assertNotEqual(len(todo_all_fields), len(guest_permitted_fields)) + + # everyone should have access to all fields of core doctypes + with set_user("Guest"): + picked_doctype = choice(core_doctypes_list) + core_permitted_fields = get_permitted_fields(picked_doctype) + picked_doctype_all_columns = frappe.get_meta(picked_doctype).get_valid_columns() + self.assertSequenceEqual(core_permitted_fields, picked_doctype_all_columns) + + +@contextmanager +def set_user(user: str): + past_user = frappe.session.user or "Administrator" + frappe.set_user(user) + yield + frappe.set_user(past_user) From b4b3dd2318fed940ed488f0896a985ed262533a8 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 3 Feb 2023 21:26:33 +0530 Subject: [PATCH 254/407] feat: FrappeTestCase.assertSequenceSubset Util for checking if a sequence is a subset of another --- frappe/tests/utils.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py index 5f13c9cd11..fe95960518 100644 --- a/frappe/tests/utils.py +++ b/frappe/tests/utils.py @@ -3,6 +3,7 @@ import datetime import signal import unittest from contextlib import contextmanager +from typing import Sequence import frappe from frappe.model.base_document import BaseDocument @@ -39,6 +40,10 @@ class FrappeTestCase(unittest.TestCase): return super().setUpClass() + def assertSequenceSubset(self, larger: Sequence, smaller: Sequence, msg=None): + """Assert that `expected` is a subset of `actual`.""" + self.assertTrue(set(smaller).issubset(set(larger)), msg=msg) + # --- Frappe Framework specific assertions def assertDocumentEqual(self, expected, actual): """Compare a (partial) expected document with actual Document.""" From f9eff18fd00d43f0dfe785ff8819ade8c6b04aa2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 3 Feb 2023 22:12:26 +0530 Subject: [PATCH 255/407] fix(meta): Remove faulty permitted fields cache The Meta property didn't respect user parameter passed --- frappe/model/meta.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 0f785be0bf..07fa65735c 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -534,15 +534,14 @@ class Meta(Document): def get_permitted_fieldnames(self, parenttype=None, *, user=None): """Build list of `fieldname` with read perm level and all the higher perm levels defined.""" - if not hasattr(self, "permitted_fieldnames"): - self.permitted_fieldnames = [] - permlevel_access = set(self.get_permlevel_access("read", parenttype, user=user)) + permitted_fieldnames = [] + permlevel_access = set(self.get_permlevel_access("read", parenttype, user=user)) - for df in self.get_fieldnames_with_value(with_field_meta=True, with_virtual_fields=True): - if df.permlevel in permlevel_access: - self.permitted_fieldnames.append(df.fieldname) + for df in self.get_fieldnames_with_value(with_field_meta=True, with_virtual_fields=True): + if df.permlevel in permlevel_access: + permitted_fieldnames.append(df.fieldname) - return self.permitted_fieldnames + return permitted_fieldnames def get_permlevel_access(self, permission_type="read", parenttype=None, *, user=None): has_access_to = [] From c18c73d8c52555cc84166016a810a502c74b2aa1 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 3 Feb 2023 22:23:14 +0530 Subject: [PATCH 256/407] test: Add test for get_permitted_fields without parenttype --- frappe/tests/test_model_utils.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frappe/tests/test_model_utils.py b/frappe/tests/test_model_utils.py index e8d62a48cd..00a73746e2 100644 --- a/frappe/tests/test_model_utils.py +++ b/frappe/tests/test_model_utils.py @@ -49,6 +49,16 @@ class TestModelUtils(FrappeTestCase): picked_doctype_all_columns = frappe.get_meta(picked_doctype).get_valid_columns() self.assertSequenceEqual(core_permitted_fields, picked_doctype_all_columns) + # access to child tables' fields is restricted to default fields unless parent is passed & permitted + with set_user("Administrator"): + without_parent_fields = get_permitted_fields("Installed Application") + with_parent_fields = get_permitted_fields( + "Installed Application", parenttype="Installed Applications" + ) + child_all_fields = frappe.get_meta("Installed Application").get_valid_columns() + self.assertLess(len(without_parent_fields), len(with_parent_fields)) + self.assertSequenceEqual(set(with_parent_fields), set(child_all_fields)) + @contextmanager def set_user(user: str): From 790b09f95fc3844bcc90314b984bf23568b28ff7 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 3 Feb 2023 22:25:27 +0530 Subject: [PATCH 257/407] feat: Allow clearing access logs --- frappe/core/doctype/access_log/access_log.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frappe/core/doctype/access_log/access_log.py b/frappe/core/doctype/access_log/access_log.py index ca2909b970..c194f5d603 100644 --- a/frappe/core/doctype/access_log/access_log.py +++ b/frappe/core/doctype/access_log/access_log.py @@ -8,7 +8,13 @@ from frappe.utils import cstr class AccessLog(Document): - pass + @staticmethod + def clear_old_logs(days=30): + from frappe.query_builder import Interval + from frappe.query_builder.functions import Now + + table = frappe.qb.DocType("Access Log") + frappe.db.delete(table, filters=(table.modified < (Now() - Interval(days=days)))) @frappe.whitelist() From 4648c287f94233106d9cdccbc92369271451a430 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 3 Feb 2023 22:29:22 +0530 Subject: [PATCH 258/407] fix(meta): get_permitted_fields Don't return any fields if user doesn't have permission for at least one field --- frappe/model/__init__.py | 17 ++++++++--------- frappe/tests/test_model_utils.py | 12 +++++++++--- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index 9ac9f4396e..e86db59776 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -199,14 +199,13 @@ def get_permitted_fields( if doctype in core_doctypes_list: return valid_columns - meta_fields = meta.default_fields.copy() - optional_meta_fields = [x for x in optional_fields if x in valid_columns] + if permitted_fields := meta.get_permitted_fieldnames(parenttype=parenttype, user=user): + meta_fields = meta.default_fields.copy() + optional_meta_fields = [x for x in optional_fields if x in valid_columns] - if meta.istable: - meta_fields.extend(child_table_fields) + if meta.istable: + meta_fields.extend(child_table_fields) - return ( - meta_fields - + meta.get_permitted_fieldnames(parenttype=parenttype, user=user) - + optional_meta_fields - ) + return meta_fields + permitted_fields + optional_meta_fields + + return [] diff --git a/frappe/tests/test_model_utils.py b/frappe/tests/test_model_utils.py index 00a73746e2..ee2dfec628 100644 --- a/frappe/tests/test_model_utils.py +++ b/frappe/tests/test_model_utils.py @@ -36,11 +36,10 @@ class TestModelUtils(FrappeTestCase): todo_all_columns = frappe.get_meta("ToDo").get_valid_columns() self.assertListEqual(todo_all_fields, todo_all_columns) - # Guest should have access to only default fields in ToDo + # Guest should have access to no fields in ToDo with set_user("Guest"): guest_permitted_fields = get_permitted_fields("ToDo") - self.assertSequenceSubset(todo_all_fields, guest_permitted_fields) - self.assertNotEqual(len(todo_all_fields), len(guest_permitted_fields)) + self.assertEqual(guest_permitted_fields, []) # everyone should have access to all fields of core doctypes with set_user("Guest"): @@ -59,6 +58,13 @@ class TestModelUtils(FrappeTestCase): self.assertLess(len(without_parent_fields), len(with_parent_fields)) self.assertSequenceEqual(set(with_parent_fields), set(child_all_fields)) + # guest has access to no fields + with set_user("Guest"): + self.assertEqual(get_permitted_fields("Installed Application"), []) + self.assertEqual( + get_permitted_fields("Installed Application", parenttype="Installed Applications"), [] + ) + @contextmanager def set_user(user: str): From 471290bd9271442616b523071dc483a0ebb429b7 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 6 Feb 2023 12:39:44 +0530 Subject: [PATCH 259/407] test: Make test_link_field_order re-runable --- frappe/tests/test_search.py | 43 +++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/frappe/tests/test_search.py b/frappe/tests/test_search.py index fdcf005da8..4f8e35301a 100644 --- a/frappe/tests/test_search.py +++ b/frappe/tests/test_search.py @@ -15,10 +15,7 @@ class TestSearch(FrappeTestCase): def setUp(self): if self._testMethodName == "test_link_field_order": setup_test_link_field_order(self) - - def tearDown(self): - if self._testMethodName == "test_link_field_order": - teardown_test_link_field_order(self) + self.addCleanup(teardown_test_link_field_order, self) def test_search_field_sanitizer(self): # pass @@ -146,24 +143,28 @@ def setup_test_link_field_order(TestCase): TestCase.parent_doctype_name = "All Territories" # Create Tree doctype - TestCase.tree_doc = frappe.get_doc( - { - "doctype": "DocType", - "name": TestCase.tree_doctype_name, - "module": "Custom", - "custom": 1, - "is_tree": 1, - "autoname": "field:random", - "fields": [{"fieldname": "random", "label": "Random", "fieldtype": "Data"}], - } - ).insert() - TestCase.tree_doc.search_fields = "parent_test_tree_order" - TestCase.tree_doc.save() + if not frappe.db.exists("DocType", TestCase.tree_doctype_name): + TestCase.tree_doc = frappe.get_doc( + { + "doctype": "DocType", + "name": TestCase.tree_doctype_name, + "module": "Custom", + "custom": 1, + "is_tree": 1, + "autoname": "field:random", + "fields": [{"fieldname": "random", "label": "Random", "fieldtype": "Data"}], + } + ).insert() + TestCase.tree_doc.search_fields = "parent_test_tree_order" + TestCase.tree_doc.save() + else: + TestCase.tree_doc = frappe.get_doc("DocType", TestCase.tree_doctype_name) # Create root for the tree doctype - frappe.get_doc( - {"doctype": TestCase.tree_doctype_name, "random": TestCase.parent_doctype_name, "is_group": 1} - ).insert() + if not frappe.db.exists(TestCase.tree_doctype_name, {"random": TestCase.parent_doctype_name}): + frappe.get_doc( + {"doctype": TestCase.tree_doctype_name, "random": TestCase.parent_doctype_name, "is_group": 1} + ).insert(ignore_if_duplicate=True) # Create children for the root for child_name in TestCase.child_doctypes_names: @@ -173,7 +174,7 @@ def setup_test_link_field_order(TestCase): "random": child_name, "parent_test_tree_order": TestCase.parent_doctype_name, } - ).insert() + ).insert(ignore_if_duplicate=True) TestCase.child_doctype_list.append(temp) From 18395b66f093c3dafde69a81ad21d2a5b00628b0 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 6 Feb 2023 12:42:06 +0530 Subject: [PATCH 260/407] fix(get_permitted_fieldnames): Return all fields if permissions not defined --- frappe/model/meta.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 07fa65735c..b6c2895c96 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -533,8 +533,15 @@ class Meta(Document): return self.high_permlevel_fields def get_permitted_fieldnames(self, parenttype=None, *, user=None): - """Build list of `fieldname` with read perm level and all the higher perm levels defined.""" + """Build list of `fieldname` with read perm level and all the higher perm levels defined. + + Note: If permissions are not defined for DocType, return all the fields with value. + """ permitted_fieldnames = [] + + if not self.get_permissions(parenttype=parenttype): + return self.get_fieldnames_with_value() + permlevel_access = set(self.get_permlevel_access("read", parenttype, user=user)) for df in self.get_fieldnames_with_value(with_field_meta=True, with_virtual_fields=True): From 5829dabf74565b2d7aa67484d47b1805e904a239 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 6 Feb 2023 12:45:02 +0530 Subject: [PATCH 261/407] fix: Add DefaultValue to core_doctypes_list --- frappe/model/__init__.py | 1 + frappe/model/db_query.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/frappe/model/__init__.py b/frappe/model/__init__.py index e86db59776..f6c6ee0a21 100644 --- a/frappe/model/__init__.py +++ b/frappe/model/__init__.py @@ -95,6 +95,7 @@ optional_fields = ("_user_tags", "_comments", "_assign", "_liked_by", "_seen") table_fields = ("Table", "Table MultiSelect") core_doctypes_list = ( + "DefaultValue", "DocType", "DocField", "DocPerm", diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index f6002df53f..c42c0bc00a 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -589,7 +589,18 @@ class DatabaseQuery: self.fields.pop(idx) def apply_fieldlevel_read_permissions(self): - """Apply fieldlevel read permissions to the query""" + """Apply fieldlevel read permissions to the query + + Note: Does not apply to `frappe.model.core_doctype_list` + + Remove fields that user is not allowed to read. If `fields=["*"]` is passed, only permitted fields will + be returned. + + Example: + - User has read permission only on `title` for DocType `Note` + - Query: fields=["*"] + - Result: fields=["title", ...] // will also include Frappe's meta field like `name`, `owner`, etc. + """ if self.flags.ignore_permissions: return From db209cbdf7d367f91fc89524a1539cc2bc990930 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 6 Feb 2023 13:33:49 +0530 Subject: [PATCH 262/407] fix: Permit no fields if dt is table and no parenttype is specified --- frappe/model/meta.py | 3 +++ frappe/tests/test_model_utils.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/model/meta.py b/frappe/model/meta.py index b6c2895c96..acc3b28477 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -539,6 +539,9 @@ class Meta(Document): """ permitted_fieldnames = [] + if self.istable and not parenttype: + return permitted_fieldnames + if not self.get_permissions(parenttype=parenttype): return self.get_fieldnames_with_value() diff --git a/frappe/tests/test_model_utils.py b/frappe/tests/test_model_utils.py index ee2dfec628..25523012e9 100644 --- a/frappe/tests/test_model_utils.py +++ b/frappe/tests/test_model_utils.py @@ -48,13 +48,14 @@ class TestModelUtils(FrappeTestCase): picked_doctype_all_columns = frappe.get_meta(picked_doctype).get_valid_columns() self.assertSequenceEqual(core_permitted_fields, picked_doctype_all_columns) - # access to child tables' fields is restricted to default fields unless parent is passed & permitted + # access to child tables' fields is restricted to no fields unless parent is passed & permitted with set_user("Administrator"): without_parent_fields = get_permitted_fields("Installed Application") with_parent_fields = get_permitted_fields( "Installed Application", parenttype="Installed Applications" ) child_all_fields = frappe.get_meta("Installed Application").get_valid_columns() + self.assertEqual(without_parent_fields, []) self.assertLess(len(without_parent_fields), len(with_parent_fields)) self.assertSequenceEqual(set(with_parent_fields), set(child_all_fields)) From 769f47cb2c7411faf107de15f2c2e729d2141630 Mon Sep 17 00:00:00 2001 From: Mehmet Demirel Date: Tue, 7 Feb 2023 20:04:12 +0300 Subject: [PATCH 263/407] Update tr.csv We are the ERPNext Turkey Team. We request confirmation of the translation we sent. SelenSoft Software - Turkey --- frappe/translations/tr.csv | 1464 +++++++++++++++++++----------------- 1 file changed, 754 insertions(+), 710 deletions(-) diff --git a/frappe/translations/tr.csv b/frappe/translations/tr.csv index 93f1174056..d81a953021 100644 --- a/frappe/translations/tr.csv +++ b/frappe/translations/tr.csv @@ -2,11 +2,11 @@ A4,A4, API Endpoint,API Bitiş Noktası, API Key,API Anahtarı, Access Token,Erişim Anahtarı, -Account,hesap, -Accounts Manager,Hesap Yöneticisi, +Account,Muhasebe, +Accounts Manager,Muhasebe Yöneticisi, Accounts User,Muhasebe Kullanıcıları, -Action,Eylem, -Actions,Eylemler, +Action,İşlem, +Actions,İşlemler, Active,Etkin, Add,Ekle, Add Comment,Yorum Ekle, @@ -22,7 +22,7 @@ Amended From,İtibaren değiştirilmiş, Amount,Tutar, Applicable For,İçin uygulanabilir, Approval Status,Onay Durumu, -Assign,Atamak, +Assign,Ata, Assign To,Ata, Attachment,Haciz, Attachments,Eklentiler, @@ -37,61 +37,63 @@ Category,Kategori, Category Name,Kategori Adı, City,İl, City/Town,İl / İlçe, -Client,Müşteri:, -Client ID,Müşteri Kimliği, -Client Secret,Müşteri Gizliliği, +Client,Client:, +Client ID,Client ID, +Client Secret,Client Secret, Closed,Kapalı, -Code,Kod, -Collapse All,Tüm daraltmak, +Code,Kodu, +Collapse All,Tümünü Daralt, Color,Renk, Company Name,Firma Adı, Condition,Koşul, Contact,İrtibat, -Contact Details,İletişim Bilgileri, +Contact Details,İrtibat Bilgileri, Content,İçerik, Content Type,İçerik Türü, Create,Oluştur, -Created By,Tarafından oluşturulan, -Current,şimdiki, +Created By,Oluşturan, +Current,Geçerli, Custom HTML,Özel HTML, Custom?,Özel?, +Customizations Discarded,Özelleştirmeler Silindi, Date Format,Tarih Biçimi, Datetime,Tarihzaman, Day,Gün, -Default Letter Head,Mektubu Başkanı Standart, -Defaults,Standart Değerler, +Dismiss,Reddet, +Default Letter Head,Varsayılan Mektubu Başlığı, +Defaults,Varsayılan Değerler, Delivery Status,Teslim Durumu, Department,Departman, Details,ayrıntılar, Document Name,Belge Adı, Document Status,Belge Durumu, Document Type,Belge Türü, -Domain,Etki Alanı, -Domains,Çalışma Alanları, -Draft,taslak, +Domain,Domain, +Domains,Domains, +Draft,Taslak, Edit,Düzenle, Email Account,E-posta Hesabı, -Email Address,E, +Email Address,E-posta Adresi, Email ID,Email kimliği, -Email Sent,E-posta Gönderilmiş, -Email Template,E-posta şablonu, +Email Sent,E-posta Gönderildi, +Email Template,E-posta Şablonu, Enable,Etkinleştir, Enabled,Etkin, -End Date,Bitiş tarihi, +End Date,Bitiş Tarihi, Error Code: {0},Hata kodu {0}, -Error Log,hata Günlüğü, +Error Log,Hata Günlüğü, Event,Faaliyet, -Expand All,Hepsini genişlet, +Expand All,Tümünü Genişlet, Fail,Başarısız, Failed,Başarısız, Fax,Faks, Feedback,Geri bildirim, Female,Kadın, Field Name,Alan Adı, -Fieldname,fieldname, +Fieldname,Alanadı, Fields,Alanlar, -First Name,Ad, -Frequency,frekans, +First Name,Adı, +Frequency,Frekans, Friday,Cuma, From,Itibaren, Full,Tam, @@ -107,32 +109,33 @@ Hourly,Saatlik, Hub Sync ID,Hub Senkronizasyon Kimliği, IP Address,IP adresi, Image,Resim, -Image View,Resim Görüntüle, +Image View,Resmi Göster, Import Data,Verileri İçe Aktar, Import Log,Günlüğü İçe Aktar, -Inactive,etkisiz, +Inactive,Etkisiz, Insert,Ekle, -Interests,İlgi, +Interests,İlgi alanları, Introduction,Giriş, -Is Active,Aktif, -Is Completed,Tamamlandı, -Is Default,Standart, -Kanban Board,Kanban Kurulu, +Is Active,Aktif mi, +Is Completed,Tamamlandı mı, +Is Default,Standart mı, +Kanban Board,KanBoard, Label,Etiket, Language Name,Dil Adı, Last Name,Soyadı, -Leaderboard,Liderler Sıralaması, +Leaderboard,Liderlik Tablosu, Letter Head,Antetli Kağıt, Level,Seviye, -Limit,sınır, -Log,Giriş, -Logs,Kayıtlar, +Limit,Limit, +Log,Log, +Logs,Loglar, Low,Düşük, -Maintenance Manager,Bakım Müdürü, -Maintenance User,Bakımcı Kullanıcı, +Maintenance,Bakım, +Maintenance Manager,Bakım Yöneticisi, +Maintenance User,Bakım Kullanıcısı, Male,Erkek, Mandatory,Zorunlu, -Mapping,haritalama, +Mapping,Eşleme, Mapping Type,Eşleme Türü, Medium,Orta, Meeting,Toplantı, @@ -147,19 +150,19 @@ More Information,Daha fazla bilgi, More...,Daha..., Move,Hareket, My Account,Hesabım, -New Address,Yeni adres, -New Contact,Yeni bağlantı, +New Address,Yeni Adres, +New Contact,Yeni İlgili Kişi, Next,İleri, -No Data,Hiçbir veri, +No Data,Hiç Veri yok, No address added yet.,Hiçbir adres Henüz eklenmiş., No contacts added yet.,Hiç kişiler Henüz eklenmiş., No items found.,Hiç bir öğe bulunamadı., None,Yok, -Not Permitted,İzin Değil, +Not Permitted,İzin verilmedi, Not active,Aktif Değil, Notes,Notlar, Number,Numara, -Online,İnternet üzerinden, +Online,Online, Operation,Operasyon, Options,Seçenekler, Other,Diğer, @@ -167,7 +170,7 @@ Owner,Sahibi, Page Missing or Moved,Sayfa yok ya da taşınmış, Parameter,Parametre, Password,Parola, -Payment Gateway,Ödeme Gateway, +Payment Gateway,Ödeme Ağ Geçidi, Payment Gateway Name,Ödeme Ağ Geçidi Adı, Payments,Ödemeler, Period,Dönem, @@ -181,16 +184,17 @@ Portal,Portal, Portal Settings,Portal Ayarları, Preview,Önizleme, Primary,Birincil, -Print Format,Yazdırma Formatı, -Print Settings,Yazdırma Ayarları, +Print Format,Baskı Formatı, +Print Settings,Baskı Ayarları, Print taxes with zero amount,Sıfır tutarlı vergileri yazdırın, Private,Özel, Property,Özellik, +Profile,Profil, Public,Genel, Published,Yayınlandı, Purchase Manager,Satınalma Yöneticisi, -Purchase Master Manager,Satınalma Usta Müdürü, -Purchase User,Satınalma Kullanıcı, +Purchase Master Manager,Satınalma Master Yöneticisi, +Purchase User,Satınalma Kullanıcısı, Query Options,Sorgu Seçenekleri, Range,Aralık, Rating,Değerlendirme, @@ -199,29 +203,29 @@ Recipients,Alıcılar, Redirect URL,Yönlendirme URL, Reference,Referans, Reference Date,Referans Tarihi, -Reference Document,referans Belgesi, +Reference Document,Referans Belgesi, Reference Document Type,Referans Belge Türü, -Reference Owner,referans Sahibi, +Reference Owner,Referans Sahibi, Reference Type,Referans Tipi, -Refresh Token,Yenile Jetonu, +Refresh Token,Jetonu Yenile, Region,Bölge, Rejected,Reddedildi, -Reopen,Yeniden açmak, +Reopen,Yeniden aç, Replied,Cevaplandı, Report,Rapor, Report Builder,Rapor Oluşturucu, Report Type,Rapor Türü, Reports,Raporlar, Response,Tepki, -Role,rol, +Role,Roller, Route,Rota, -Sales Manager,Satış Müdürü, -Sales Master Manager,Satış Master Müdürü, -Sales User,Satış Kullanıcı, +Sales Manager,Satış Yöneticisi, +Sales Master Manager,Satış Master Yöneticisi, +Sales User,Satış Kullanıcısı, Salutation,Hitap, Sample,Numune, Saturday,Cumartesi, -Saved,Kaydedilmiş, +Saved,Kaydedildi, Scan Barcode,Barkod Tara, Scheduled,Planlandı, Search,Arama, @@ -239,16 +243,16 @@ Short Name,Kısa Adı, Slideshow,Slayt Gösterisi, Some information is missing,Bazı bilgiler eksik, Source,Kaynak, -Source Name,kaynak Adı, +Source Name,Kaynak Adı, Standard,Standart, Start Date,Başlangıç Tarihi, Start Import,İçe Aktarmayı Başlat, -State,"Belirtmek, bildirmek", +State,Eyalet, Stopped,Durduruldu, -Subject,konu, +Subject,Konu, Submit,Gönder, Successful,Başarılı, -Summary,özet, +Summary,Özet, Sunday,Pazar, System Manager,Sistem Yöneticisi, Target,Hedef, @@ -258,14 +262,15 @@ Test,Test, Thank you,Teşekkürler, The page you are looking for is missing. This could be because it is moved or there is a typo in the link.,Aradığınız sayfa eksik. taşınması veya linkte bir yazım hatası yoktur Bunun nedeni olabilir., Timespan,Zaman aralığı, -To,için, -To Date,Tarihine kadar, +To,Bitiş, +To Date,Bitiş Tarihi, Tools,Araçlar, -Traceback,Geri iz, +Traceback,Geri takip, +Translator,Çevirmen, URL,URL, -Unsubscribed,Kaydolmamış, +Unsubscribed,Abonelik iptal edildi, Use Sandbox,Kullanım Sandbox, -User,kullanıcı, +User,Kullanıcı, User ID,Kullanıcı kimliği, Users,Kullanıcılar, Validity,Geçerlilik, @@ -278,22 +283,22 @@ Week,Hafta, Weekdays,Hafta içi, Weekly,Haftalık, Welcome email sent,Hoşgeldiniz e-posta adresine gönderildi, -Workflow,İş Akışı, +Workflow,Workflow, +Add a comment,Yorum Ekle, You need to be logged in to access this page,Bu sayfaya erişmek için giriş yapmış olmanız gerekmektedir, old_parent,old_parent, {0} is mandatory,{0} alanı zorunludur, - to your browser,tarayıcına, +to your browser,tarayıcına, """Company History""","""Şirket Tarihçesi""", """Parent"" signifies the parent table in which this row must be added","""Ana"" bu satırın ekleneceği ana tabloyu belirtir", """Team Members"" or ""Management""","""Takım Üyeleri"" veya ""Yönetim""", -<head> HTML,<head> HTML, 'In Global Search' not allowed for type {0} in row {1},{1} satırında {0} türü için 'Genel Arama'ya izin verilmiyor, 'In List View' not allowed for type {0} in row {1},'Liste görüntüle' izin türü için {0} üst üste {1}, -'Recipients' not specified,'Alıcılar' belirtilmemiş, +'Recipients' not specified,'Alıcılar' belirtilmemiş, (Ctrl + G),(Ctrl + G), ** Failed: {0} to {1}: {2},** Başarısız: {0} için {1}: {2}, **Currency** Master,** Para ** Ana, -0 - Draft; 1 - Submitted; 2 - Cancelled,0 - Taslak; 1 - Gönderildi ; 2 - İptal Edildi, +"0 - Draft; 1 - Submitted; 2 - Cancelled","0 - Taslak; 1 - Gönderildi; 2 - İptal Edildi", 0 is highest,0 en üsttedir, 1 Currency = [?] Fraction\nFor e.g. 1 USD = 100 Cent,1 Döviz = [?] Örneğin 1 TKY = 100 Kuruş, 1 comment,1 yorum, @@ -301,7 +306,7 @@ old_parent,old_parent, 1 minute ago,1 dakika önce, 1 month ago,1 ay önce, 1 year ago,1 yıl önce, -; not allowed in condition,; Durumda izin verilmiyor, +"; not allowed in condition","; Durumda izin verilmiyor", "

Default Template

\n

Uses Jinja Templating and all the fields of Address (including Custom Fields if any) will be available

\n
{{ address_line1 }}<br>\n{% if address_line2 %}{{ address_line2 }}<br>{% endif -%}\n{{ city }}<br>\n{% if state %}{{ state }}<br>{% endif -%}\n{% if pincode %} PIN:  {{ pincode }}<br>{% endif -%}\n{{ country }}<br>\n{% if phone %}Phone: {{ phone }}<br>{% endif -%}\n{% if fax %}Fax: {{ fax }}<br>{% endif -%}\n{% if email_id %}Email: {{ email_id }}<br>{% endif -%}\n
","

Varsayılan Şablon \n

Jinja şablonu ve Adres tüm alanları (kullanır {; br & gt;, \n {% eğer address_line2%} {{address_line2}} & lt; br & gt Özel Alanlar varsa) \n

  {{address_line1}} & lt sunulacak dahil % endif -%} \n {{şehir}} & lt; br & gt; \n {% eğer devlet%} {{devlet}} & lt; br & gt; {% endif -%} {% \n eğer pin kodunu%} PIN: {{pin}} & lt; br & gt; {% endif -%} \n {{ülke}} & lt; br & gt; \n {% eğer telefon%} Telefon: {{telefon}} & lt; br & gt; { % endif -%} \n {% takdirde faks%} Faks: {{faks}} & lt; br & gt; {% endif -%} \n {% eğer email_id%} E-posta: {{email_id}} & lt; br & gt {% endif -%} \n  

", A Lead with this Email Address should exist,Bu e-posta adresiyle bir Müşteri Adayı bulunmalıdır, A list of resources which the Client App will have access to after the user allows it.
e.g. project,"Müşteri App kullanıcı izin verdiği sonra erişimi olacak kaynakların listesi.
örneğin, proje", @@ -331,9 +336,9 @@ Add / Manage Email Domains.,E-posta alan adlarını Ekle/Yönet, Add / Update,Ekle / Güncelle, Add A New Rule,Yeni Kural Ekle, Add Another Comment,Bir yorum daha ekle, -Add Attachment,Ek ekle, -Add Column,Sütun ekle, -Add Contact,Kişi ekle, +Add Attachment,Ek dosya Ekle, +Add Column,Sütun Ekle, +Add Contact,Kişi Ekle, Add Contacts,Kişileri ekleyin, Add Filter,Filtre Ekle, Add Group,Grup ekle, @@ -344,12 +349,13 @@ Add Subscribers,Abone Ekle, Add Total Row,Toplam satır ekle, Add Unsubscribe Link,Aboneliğini Bağlantısı Ekle, Add User Permissions,Kullanıcı İzinleri Ekleyin, +Add a Filter,Bir Filtre Ekle, Add a New Role,Yeni Rol Ekle, -Add a column,Sütun ekle, -Add a comment,Yorum ekle, +Add a column,Sütun Ekle, +Add a comment,Yorum Ekle, Add a new section,Yeni bölüm ekle, Add a tag ...,Etiket ekle ..., -Add all roles,Rol Ekleyin, +Add all roles,Tüm Rolleri Ekle, Add custom forms.,Özelleştirilmiş formlar ekle, Add custom javascript to forms.,Formlara özel javascript ekle., Add fields to forms.,Formlara ekstra alan ekleme., @@ -365,27 +371,27 @@ Address Template,Adres Şablonu, Address Title is mandatory.,Adres Başlığı zorunludur., Address and other legal information you may want to put in the footer.,Adres ve diğer yasal bilgileri altbilgi kısmına koyabilirsiniz, Addresses And Contacts,İrtibatlar ve Adresler, -Adds a client custom script to a DocType,Bir DocType'a istemci özel komut dosyası ekler, +Adds a client custom script to a DocType,Bir Belge Tipine özel bir client script ekler, Adds a custom field to a DocType,Bir DocType için özel bir alan ekler, -Admin,yönetim, +Admin,Yönetim, Administrator Logged In,Yönetici Giriş Yaptı, Administrator accessed {0} on {1} via IP Address {2}.,Yönetici {1} üzerindeki {0}'a {2} IP Adresinden erişti., Advanced,Gelişmiş, Advanced Control,Gelişmiş Kontrol, Advanced Search,Gelişmiş Arama, Align Labels to the Right,Etiketleri Sağa hizalayın, -Align Value,Değeri hizala, +Align Value,Değeri Hizala, All Images attached to Website Slideshow should be public,Web Sitesi Slayt gösterisine eklenen tüm resimler herkese açık olmalıdır, All customizations will be removed. Please confirm.,Tüm özelleştirmeler silinecektir. Onaylayın., -"All possible Workflow States and roles of the workflow. Docstatus Options: 0 is""Saved"", 1 is ""Submitted"" and 2 is ""Cancelled""","Mümkün olan tüm İş Akışı Devletler ve iş akışı rolleri. Docstatus Seçenekleri: 0 "Kaydedildi", 1 "Ekleyen" ve 2 "İptal" olduğunu", +"All possible Workflow States and roles of the workflow. Docstatus Options: 0 is""Saved"", 1 is ""Submitted"" and 2 is ""Cancelled""","Mümkün olan tüm İş Akışı Durumlar ve iş akışı rolleri. Docstatus Seçenekleri: 0 'Kaydedildi', 1 'Gönderildi' ve 2 'İptal edildi' ", All-uppercase is almost as easy to guess as all-lowercase.,"Hepsi büyük harf, hepsi küçük harf kadar kolay tahmin edilebilir.", Allocated To,Atanan, -Allow,İzin vermek, +Allow,İzin ver, Allow Bulk Edit,Toplu düzenlemeye izin ver, Allow Comments,Yorumlara izin ver, Allow Consecutive Login Attempts ,Ardışık Giriş Denemelerine İzin Ver, Allow Dropbox Access,Dropbox erişimine izin ver, -Allow Edit,Düzenle İzin, +Allow Edit,İzin Düzenle, Allow Guest to View,Misafirin görüntülemesine izin ver, Allow Import (via Data Import Tool),İçe aktarıma izin ver (Veri Alma Aracı ile), Allow Incomplete Forms,Eksik Formlara izin ver, @@ -403,7 +409,7 @@ Allow Roles,Rollere izin ver, Allow Self Approval,Kendi Onayına İzin Ver, Allow approval for creator of the document,Belgenin yaratıcısı için onay ver, Allow events in timeline,Zaman çizelgesindeki etkinliklere izin ver, -Allow in Quick Entry,Hızlı Giriş'e İzin Ver, +Allow in Quick Entry,Hızlı Giriş'e İzin Ver, Allow on Submit,Gönderme Onayı İzni, Allow only one session per user,Kullanıcı başına yalnızca bir oturuma izin ver, Allow page break inside tables,Tablo içerisinde sayfa sonlarına izin ver, @@ -416,13 +422,13 @@ Allowed In Mentions,Yorumlarda İzin Verildi, Already Registered,Zaten Kayıtlı, Also adding the dependent currency field {0},Ayrıca bağımlı para birimi alanını {0} ekleyerek, "Always add ""Draft"" Heading for printing draft documents","Taslak belgeleri yazdırırken her zaman ""Taslak"" başlığını ekle", -Always use Account's Email Address as Sender,Daima Gönderen olarak Hesabının E-posta Adresi kullanmak, +Always use Account's Email Address as Sender,Daima Gönderen olarak Hesabının E-posta Adresi kullan, Always use Account's Name as Sender's Name,Her zaman Hesap Adını Gönderenin Adı olarak kullan, Amend,Değiştir, Amending,Değiştirilen, Amount Based On Field,Alan Bazlı Tutar, Amount Field,tutar Alan, -Amount must be greater than 0.,Miktar 0'dan büyük olmalıdır., +Amount must be greater than 0.,Miktar 0'dan büyük olmalıdır., An error occured during the payment process. Please contact us.,Ödeme işlemi sırasında bir hata oluştu. Lütfen bizimle iletişime geçin., An icon file with .ico extension. Should be 16 x 16 px. Generated using a favicon generator. [favicon-generator.org],.ico Uzantılı bir simge dosyası. 16 x 16 px olmalıdır. Bir favicon jeneratörü kullanılarak oluşturulan. [favicon-generator.org], Ancestors Of,Ataları, @@ -445,15 +451,16 @@ Append To can be one of {0},{0} biri ile ilişkilendirilebilir, Append To is mandatory for incoming mails,Için Gelen postalar için zorunludur Append, "Append as communication against this DocType (must have fields, ""Status"", ""Subject"")","(Alanları, ""Durum"" olmalı ""Konu"") Bu DocType karşı iletişim gibi ekler", Applicable Document Types,Uygulanabilir Belge Türleri, -Apply,Uygulamak, +Apply,Uygula, +Apply Filters,Filtreyi Uygula, Apply Strict User Permissions,Katı Kullanıcı Yetkilerini Uygula, Apply To All Document Types,Tüm Belge Türlerine Uygula, Apply this rule if the User is the Owner,Kullanıcı Sahibi ise bu kuralı uygula, Apply to all Documents Types,Tüm Belge Türlerine Uygula, -Appreciate,takdir etmek, -Appreciation,takdir, +Appreciate,Takdir et, +Appreciation,Takdir, Archive,Arşiv, -Archived,Arşivlenen, +Archived,Arşivlendi, Archived Columns,Arşivlenen Sütunlar, Are you sure you want to delete the attachment?,Eki silmek istediğinizden emin misiniz?, Are you sure you want to relink this communication to {0}?,Eğer {0} bu iletişimi yeniden bağlamak istediğinden emin misin?, @@ -465,15 +472,15 @@ Assign To Users,Kullanıcılara Atama, "Assign one by one, in sequence",Sırayla birer birer atayın, Assign to me,Bana Ata, Assign to the one who has the least assignments,En az ödeve sahip olana tayin edin, -Assigned,atanan, -Assigned By,By Assigned, +Assigned,Atanan, +Assigned By,Atayan, Assigned By Full Name,Bilinen Tam Adı, Assigned By Me,Bana Atanan, Assigned To,Atanan, Assigned To/Owner,/ Kişiye Atanan, Assignment,atama, Assignment Complete,Komple Atama, -Assignment Completed,atama Tamamlandı, +Assignment Completed,Atama Tamamlandı, Assignment Rule,Atama Kuralı, Assignment Rule User,Atama Kuralı Kullanıcısı, Assignment Rules,Atama Kuralları, @@ -481,26 +488,26 @@ Assignment closed by {0},Atama tarafından kapatıldı {0}, Assignment for {0} {1},{0} {1} için ödev, Atleast one field of Parent Document Type is mandatory,Ana belge tipinin en az bir alanı zorunludur, Attach,Ekle, -Attach Document Print,Belge Yazdır takın, -Attach Image,Görüntü Ekleyin, -Attach Print,Yazdır takın, +Attach Document Print,Ek Belgeyi Yazdır, +Attach Image,Görüntü Ekle, +Attach Print,Eki Yazdır, Attach Your Picture,Resminizi Ekleyin, -Attach file for Import,İçe Aktar için dosya ekle, -Attach files / urls and add in table.,Dosyaları / URL'leri ekleyin ve tabloya ekleyin., -Attached To DocType,DocType'a Ekle, +Attach file for Import,İçe Aktarım için dosya ekle, +Attach files / urls and add in table.,Dosyaları / URL'leri ekleyin ve tabloya ekleyin., +Attached To DocType,Belge Türüne Ekle, Attached To Field,Alana Bağlı, Attached To Name,Adı için Eklenmiş, Attachment Limit (MB),Eklenti Limiti (MB), Attachment Removed,Eklenti kaldırıldı, Attempting Connection to QZ Tray...,QZ Tepsisine Bağlantı Deneniyor ..., -Attempting to launch QZ Tray...,QZ Tray'i başlatmaya çalışıyor ..., +Attempting to launch QZ Tray...,QZ Tray'i başlatmaya çalışıyor ..., Auth URL Data,Kimlik Doğrulama URL Veri, Authenticating...,Kimlik doğrulanıyor ..., Authentication,Doğrulama, Authentication Apps you can use are: ,Kullanabileceğiniz kimlik doğrulama uygulamaları şunlardır:, Authentication Credentials,Kimlik Doğrulama Kimlik Bilgileri, Authorization Code,Yetki Kodu, -Authorize URL,URL'yi yetkilendir, +Authorize URL,URL'yi yetkilendir, Authorized,Yetkili, Auto,Otomatik, Auto Email Report,Otomatik E-posta Raporu, @@ -561,18 +568,18 @@ Braintree Settings,Braintree Ayarları, Braintree payment gateway settings,Braintree ödeme ağ geçidi ayarları, Brand HTML,Marka HTML, Brand Image,Marka imajı, -Breadcrumbs,Kırıntıları, +Breadcrumbs,Sayfa işaretleri, Browser not supported,Tarayıcı desteklenmiyor, -Brute Force Security,Kaba kuvvet güvenlik, -Build Report,Rapor oluşturmak, -Bulk Delete,Toplu Silme, -Bulk Edit {0},Toplu Düzenleme {0}, -Bulk Rename,Toplu Rename, -Bulk Update,Çoklu güncelleme, +Brute Force Security,Kaba Kuvvet Güvenliği, +Build Report,Rapor Oluştur, +Bulk Delete,Toplu Sil, +Bulk Edit {0},Toplu Düzenle {0}, +Bulk Rename,Toplu Yeniden adlandır, +Bulk Update,Toplu Güncelle, Busy,Meşgul, -Button,Düğme, -Button Help,düğme Yardımı, -Button Label,düğme Etiketi, +Button,Buton, +Button Help,Buton Yardımı, +Button Label,Buton Etiketi, Bypass Two Factor Auth for users who login from restricted IP Address,Kısıtlı IP Adresi ile giriş yapan kullanıcılar için İki Faktör Kimlik Doğrulama, Bypass restricted IP Address check If Two Factor Auth Enabled,"İki Faktör Yetkilisi Etkinleştirilmişse, Sınırlı IP Adresi kontrolünü atla", CC,CC, @@ -602,7 +609,7 @@ Cannot change state of Cancelled Document. Transition row {0},İptal Belgesinin Cannot change user details in demo. Please signup for a new account at https://erpnext.com,Demoda kullanıcı ayrıntılarını değiştiremezsiniz. Lütfen https://erpnext.com adresinden yeni bir hesap için kaydolun, Cannot create a {0} against a child document: {1},oluşturulamıyor bir {0} alt belgenin karşı: {1}, Cannot delete Home and Attachments folders,Ev ve Ekler klasörleri silemezsiniz, -Cannot delete file as it belongs to {0} {1} for which you do not have permissions,{0} {1} 'e ait olduğu için dosyanın izinleri olmadığı için silinemiyor, +Cannot delete file as it belongs to {0} {1} for which you do not have permissions,{0} {1} 'e ait olduğu için dosyanın izinleri olmadığı için silinemiyor, Cannot delete or cancel because {0} {1} is linked with {2} {3} {4},"{0} {1}, {2} {3} {4} ile bağlantılı olduğundan silinemez veya iptal edilemez", Cannot delete standard field. You can hide it if you want,Standart alan silinemiyor. İstersen bunu gizleyebilirsiniz, Cannot delete {0},{0} sililemiyor, @@ -618,7 +625,7 @@ Cannot move row,Satır taşınamıyor, Cannot open instance when its {0} is open,"Açıkken, örneği açılamaz {0}", Cannot open {0} when its instance is open,Örnek açıkken {0} açılamıyor, Cannot remove ID field,Kimlik alanı kaldırılamıyor, -Cannot set Notification on Document Type {0},Belge Türü'nde Bildirim Ayarlanamıyor {0}, +Cannot set Notification on Document Type {0},Belge Türü'nde Bildirim Ayarlanamıyor {0}, Cannot update {0},{0} güncellenemiyor, Cannot use sub-query in order by,tarafından sırayla alt sorgu kullanılamaz, Cannot {0} {1},{0} {1} olamaz, @@ -631,7 +638,7 @@ Cent,Sent, Chain Integrity,Zincir bütünlüğü, Chaining Hash,Zincirleme karma, Change Label (via Custom Translation),(Özel Çevirisi) Değişim Etiket, -Change Password,Şifre değiştir, +Change Password,Şifre Değiştir, "Change field properties (hide, readonly, permission etc.)","Değişim alan özellikleri (sakla, salt okunur, izin vb)", Channel,Kanal, Chart Name,Grafik Adı, @@ -652,7 +659,7 @@ Chat Token,Sohbet Simgesi, Chat Type,Sohbet Türü, Chat messages and other notifications.,Iletileri ve diğer bildirimleri Sohbet., Check,Kontrol, -Check Request URL,İstek URL'sini kontrol et, +Check Request URL,İstek URL'sini kontrol et, "Check columns to select, drag to set order.","Sırasını ayarlamak için, sürükle seçmek için sütunları kontrol edin.", Check this if you are testing your payment using the Sandbox API,Sandbox API kullanarak ödemenizi test eğer bu kontrol, Check this to pull emails from your mailbox,Posta kutunuzdan mail çekmek için işaretleyin, @@ -677,7 +684,7 @@ Clicked,Tıklandı, Client Credentials,Müşteri Kimlik, Client Information,Müşteri bilgisi, Client Script,İstemci Komut Dosyası, -Client URLs,Müşteri URL'leri, +Client URLs,Müşteri URL'leri, Client side script extensions in Javascript,JavaScript İstemci tarafı komut dosyası uzantıları, Collapsible,Katlanabilir, Collapsible Depends On,Katlanabilir Bağlıdır, @@ -699,7 +706,7 @@ Comments and Communications will be associated with this linked document,Yorumla Comments cannot have links or email addresses,Yorumların bağlantıları veya e-posta adresleri olamaz, Common names and surnames are easy to guess.,Ortak ad ve soyadları tahmin etmek kolaydır., Communicated via {0} on {1}: {2},aracılığıyla iletilen {0} {1}: {2}, -Communication Type,haberleşme Tipi, +Communication Type,İletişim Tipi, Company History,Şirket Tarihçesi, Company Introduction,Firma Tanıtımı, Compiled Successfully,Başarıyla Derlendi, @@ -711,19 +718,19 @@ Compose Email,E-posta oluştur, Condition Detail,Durum Ayrıntısı, Conditions,Koşullar, Configure Chart,Grafiği Yapılandır, -Configure Charts,Grafikleri Yapılandırma, -Confirm,Onaylamak, +Configure Charts,Grafikleri Yapılandır, +Confirm,Onayla, Confirm Deletion of Data,Verilerin Silinmesini Onayla, Confirm Request,İsteği Onayla, Confirm Your Email,E-posta adresiniz Onayla, Confirmed,Onaylı, -Connected to QZ Tray!,QZ Tray'e bağlı!, +Connected to QZ Tray!,QZ Tray'e bağlı!, Connection Name,Bağlantı adı, Connection Success,Bağlantı Başarı, Connection lost. Some features might not work.,Bağlantı koptu. Bazı özellikler çalışmayabilir., Connector Name,Bağlayıcı Adı, Connector Type,Bağlayıcı Türü, -Contact Us Settings,Bize Ulaşın ayarları, +Contact Us Settings,Bize Ulaşın Ayarları, "Contact options, like ""Sales Query, Support Query"" etc each on a new line or separated by commas.","'Satış sorgusu, Destek sorgusu' gibi her biri yeni bir sırada ya da virgüllerle ayrılmış, iletişim seçenekleri", Contacts,Kişiler, Content (HTML),İçerik (HTML), @@ -732,13 +739,13 @@ Content Hash,İçerik Hash, Content web page.,Içerik web sayfası., Conversation Tones,Konuşma Sesleri, Copyright,Telif hakkı, -Core,Çekirdek, -Core DocTypes cannot be customized.,Çekirdek DocTypes özelleştirilemez., +Core,Temel Ayarlar, +Core DocTypes cannot be customized.,Temel Belge Türleri özelleştirilemez., Could not connect to outgoing email server,Giden e-posta sunucusu için bağlantı kurulamadı, Could not find {0},Bulunamıyor {0}, Could not find {0} in {1},{1}'in içinde {0} bulunamadı, Could not identify {0},{0} tanımlanamadı, -Count,saymak, +Count,Sayım, Country Name,Ülke Adı, County,Kontluk, Create Chart,Grafik Oluştur, @@ -746,15 +753,15 @@ Create New,Yeni Oluştur, Create Post,Gönderi Oluştur, Create User Email,Kullanıcı E-postası Oluştur, Create a New Format,Yeni Format Oluştur, -Create a new record,Yeni bir kayıt oluştur, -Create a new {0},Yeni {0} oluşturun, -Create and Send Newsletters,Oluşturun ve gönderin Haber, +Create a new record,Yeni bir Kayıt Oluştur, +Create a new {0},Yeni bir {0} Oluştur, +Create and Send Newsletters,Haber bülteni Oluştur ve Gönder, Create and manage newsletter,Bülten oluşturun ve yönetin, Created,düzenlendi, Created Custom Field {0} in {1},Tasarlanmış özel alan {0} {1}, Created On,Oluşturulma Tarihi, Criticism,eleştiri, -Criticize,eleştirmek, +Criticize,Eleştir, Ctrl + Down,Ctrl + Aşağı, Ctrl + Up,Ctrl + Yukarı, Ctrl+Enter to add comment,Yorum eklemek için Ctrl+Enter tuşlarına basın, @@ -772,42 +779,42 @@ Custom Base URL,Özel Ana URL, Custom CSS,Özel CSS, Custom DocPerm,Özel DocPerm, Custom Field,Özel Alan, -Custom Fields can only be added to a standard DocType.,Özel Alanlar sadece standart bir DocType'a eklenebilir., -Custom Fields cannot be added to core DocTypes.,"Özel Dokümanlar, çekirdek DocTypes'e eklenemez.", +Custom Fields can only be added to a standard DocType.,Özel Alanlar sadece standart bir DocType'a eklenebilir., +Custom Fields cannot be added to core DocTypes.,"Özel Dokümanlar, çekirdek DocTypes'e eklenemez.", Custom Format,Özel Formatı, Custom HTML Help,Özel HTML Yardımı, Custom JS,Özel JS, Custom Menu Items,Özel Menü Öğeleri, -Custom Report,Özel rapor, +Custom Report,Özel Rapor, Custom Reports,Özel Raporlar, -Custom Role,Özel Rolü, +Custom Role,Özel Roller, Custom Script,Özel Komut, Custom Sidebar Menu,Özel Kenar Çubuğu Menüsü, -Custom Translations,özel Çeviriler, +Custom Translations,Özel Çeviriler, Customization,Özelleştirme, Customizations Reset,Özelleştirmeler Sıfırla, Customizations for {0} exported to:
{1},{0} için verilen özelleştirmeler şu kişilere ihraç edildi:
{1}, -Customize Form,Formu özelleştirmek, -Customize Form Field,Form alanını özelleştirmek, +Customize Form,Formu Özelleştir, +Customize Form Field,Form alanını özelleştir, "Customize Label, Print Hide, Default etc.","Özelleştirme Etiket, Baskı gizle vb.", Customize...,Özelleştirmek..., "Customized Formats for Printing, Email","Baskı, E-posta için Özelleştirilmiş Biçimleri", Customized HTML Templates for printing transactions.,Baskı işlemleri için özel HTML Şablonları., -Cut,Kesmek, +Cut,Kes, DESC,AZALAN, Daily Event Digest is sent for Calendar Events where reminders are set.,Günlük Planlar takvime gönderilir ve bunlar için alarm ayarlanır., Danger,Tehlike, Dark Color,Koyu renk, -Dashboard Chart,Gösterge Tablosu, -Dashboard Chart Link,Kontrol Paneli Grafik Bağlantısı, +Dashboard Chart,Gösterge Paneli Grafiği, +Dashboard Chart Link,Gösterge Paneli Grafik Bağlantısı, Dashboard Chart Source,Kontrol Paneli Grafik Kaynağı, Dashboard Name,Kontrol Paneli Adı, Dashboards,Gösterge tabloları, Data,Veri, -Data Export,Veri Aktarımı, -Data Import,Veri İçe Aktarma, +Data Export,Veri Aktarma, +Data Import,Veri Alma, Data Import Template,Veri Alma Şablon, -Data Migration,Veri göçü, +Data Migration,Veri Göçü, Data Migration Connector,Veri Taşıma Bağlayıcısı, Data Migration Mapping,Veri Taşıma Eşleme, Data Migration Mapping Detail,Veri Taşıma Eşleme Ayrıntısı, @@ -843,7 +850,7 @@ Default Value,Varsayılan Değer, "Default: ""Contact Us""","Default: ""Bize Ulaşın""", DefaultValue,VarsayılanDeğer, Define workflows for forms.,Formlar için iş akışlarını tanımlayın., -Defines actions on states and the next step and allowed roles.,Devletlerin eylemleri ve bir sonraki adımı ve izin verilen rolleri tanımlar., +Defines actions on states and the next step and allowed roles.,"Durumlar üzerindeki eylemleri, sonraki adımları ve izin verilen rolleri tanımlar.", Defines workflow states and rules for a document.,Bir belge için iş akışı durumları ve kuralları tanımlar., Delayed,Gecikmiş, Delete Data,Verileri Sil, @@ -851,23 +858,23 @@ Delete comment?,Yorum silinsin mi?, Delete this record to allow sending to this email address,Bu e-posta adresine gönderilmesine izin Bu kayıt silinsin, Delete {0} items permanently?,Bu {0} öğeyi kalıcı olarak silmek istediğinize emin misiniz?, Deleted,Silinen, -Deleted DocType,DocType silindi, -Deleted Document,Döküman silindi, -Deleted Documents,Silinmiş Belgeler, -Deleted Name,İsim silindi, +Deleted DocType,Silinen Belge Türü, +Deleted Document,Silinen Belge, +Deleted Documents,Silinen Belgeler, +Deleted Name,Silinen ad, Deleting {0},Siliniyor {0}, Depends On,Bağlıdır, Descendants Of,Torunları, Desk,Masa, -Desk Access,Danışma Erişim, +Desk Access,Masa Erişimi, Desktop Icon,Masaüstü simgesi, Desktop Icon already exists,Masaüstü Simgesi zaten var, Developer,Geliştirici, -Did not add,Eklemek vermedi, -Did not cancel,Iptal edilmedi, +Did not add,Eklenemedi, +Did not cancel,İptal edilemedi, Did not find {0} for {0} ({1}),Için {0} bulamadık {0} ({1}), Did not remove,Kaldırılamaz, -"Different ""States"" this document can exist in. Like ""Open"", ""Pending Approval"" etc.","Bu belge içeri var farklı ""Devletleri"" ""Aç"" gibi, ""Onay Bekliyor"" vb", +"Different ""States"" this document can exist in. Like ""Open"", ""Pending Approval"" etc.","Bu belge içeri var farklı ""Durumlar"" ""Açık"" gibi, ""Onay Bekliyor"" vb", Direct,direkt, Direct room with {0} already exists.,{0} ile doğrudan oda zaten var., Disable Auto Refresh,Otomatik Yenilemeyi Devre Dışı Bırak, @@ -884,7 +891,7 @@ Display,Görüntü, Display Depends On,Görüntü şuna bağlı, Do not allow user to change after set the first time,Kullanıcı değiştirmesine izin vermeyin sonra ilk kez ayarlayın, Do not edit headers which are preset in the template,Şablonda önceden belirlenmiş başlıkları düzenleme, -Do not send Emails,E-postalar gönderme, +Do not send Emails,E-posta gönderme, Doc Event,Doktor Etkinliği, Doc Events,Doc Etkinlikleri, Doc Status,Doc Durum, @@ -892,34 +899,34 @@ DocField,DocField, DocPerm,DocPerm, DocShare,DocShare, DocType {0} provided for the field {1} must have atleast one Link field,"{1} alanı için sağlanan DocType {0} , en az bir Link alanına sahip olmalı", -DocType can not be merged,DocType birleştirilmiş olamaz, -DocType can only be renamed by Administrator,DocType sadece Yönetici tarafından yeniden adlandırılabilir, -DocType is a Table / Form in the application.,DocType uygulamasında bir tablo / Formu olduğunu., -DocType must be Submittable for the selected Doc Event,"DocType, seçilen Dok Olayı için Gönderilebilir olmalıdır", +DocType can not be merged,Belge Türü birleştirilmiş olamaz, +DocType can only be renamed by Administrator,Belge Türü sadece Yönetici tarafından yeniden adlandırılabilir, +DocType is a Table / Form in the application.,Belge Türü uygulamasında bir tablo / Formu olduğunu., +DocType must be Submittable for the selected Doc Event,"Belge Türü, seçilen Dok Olayı için Gönderilebilir olmalıdır", DocType on which this Workflow is applicable.,Uygulanabilir iş akışı DOCTYPE., "DocType's name should start with a letter and it can only consist of letters, numbers, spaces and underscores","BELGETÜRÜ adı bir harfle başlamalıdır ve sadece harfler, sayılar, boşluklar ve alt oluşabilir", -Doctype required,Doctype gerekli, +Doctype required,Belge Türü gerekli, Document,Belge, -Document Follow,Döküman Takibi, +Document Follow,Belge Takibi, Document Follow Notification,Belge Takip Bildirimi, Document Queued,Belge sıraya alınmış, -Document Restored,Doküman Geri Yüklendi, +Document Restored,Belge Geri Yüklendi, Document Share Report,Belge Paylaş Raporu, -Document States,Belge Devletleri, +Document States,Belge Durumları, Document Type is not importable,Belge Türü içe aktarılmaz, Document Type is not submittable,Belge Türü gönderilemez, Document Type to Track,İzlenecek Belge Türü, Document Types,Belge Türleri, -Document can't saved.,Doküman kaydedilemiyor., +Document can't saved.,Belge kaydedilemiyor., Document {0} has been set to state {1} by {2},"{0} belgesi, {2} tarihinde {1} durumunu yapacak şekilde ayarlandı", Documents,Belgeler, Documents assigned to you and by you.,Size ve sizin tarafınızdan atanmış belgeler., Domain Settings,Etki Alanı Ayarları, Domains HTML,Domains HTML, -"Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field",Do not <script> ya da sadece karakterler kasten bu alanda kullanılan olabilir gibi gibi <veya> gibi HTML Encode HTML etiketleri, -Don't Override Status,Durum geçersiz kılma etmeyin, +"Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field","Do not <script> ya da sadece karakterler kasten bu alanda kullanılan olabilir gibi gibi <veya> gibi HTML Encode HTML etiketleri", +Don't Override Status,Durumu geçersiz kılma, Don't create new records,Yeni kayıtlar oluşturma, -Don't have an account? Sign up,Hesabınız mı yok mu? Kayıt ol, +"Don't have an account? Sign up","Hesabınız mı yok mu? Kayıt ol", "Don't know, ask 'help'","Bilmiyorsanız, 'Yardım' isteyin", Download Data,Veri İndir, Download Files Backup,Dosya İndirme Yedekleme, @@ -933,26 +940,26 @@ Drag elements from the sidebar to add. Drag them back to trash.,Kenar çubuğund Dropbox Access Key,Dropbox Erişim Anahtarı, Dropbox Access Secret,Dropbox Erişimi Gizli, Dropbox Access Token,Bırakma Kutusu Erişim Kartı, -Dropbox Settings,dropbox Ayarları, +Dropbox Settings,Dropbox Ayarları, Dropbox Setup,Dropbox Kurulumu, Dropbox access is approved!,Dropbox erişimi onaylandı!, Dropbox backup settings,Dropbox yedekleme ayarları, Duplicate Filter Name,Yinelenen Filtre Adı, -Dynamic Link,Dynamic Link, +Dynamic Link,Dinamik Bağlantı, Dynamic Report Filters,Dinamik Rapor Filtreleri, ESC,ESC, Edit Auto Email Report Settings,Otomatik E-posta Rapor Ayarlarını Düzenle, Edit Custom HTML,Edit Custom HTML, -Edit DocType,Düzenleme DocType, -Edit Filter,Düzen Filtre, -Edit Format,Düzen Biçimi, +Edit DocType,Belge Türünü Düzenle, +Edit Filter,Filtreyi Düzenle, +Edit Format,Biçimi Düzenle, Edit HTML,HTML Düzenle, -Edit Heading,Düzenle Başlık, +Edit Heading,Başlık Düzenle, Edit Properties,Özellikleri Düzenle, -Edit to add content,Içerik eklemek için düzenleyin, +Edit to add content,Içerik eklemek için düzenle, Edit {0},{0} düzenle, Editable Grid,Düzenlenebilir Izgara, -Editing Row,Düzenleme Satır, +Editing Row,Satır Düzenleme, Eg. smsgateway.com/api/send_sms.cgi,Örn. msgateway.com / api / send_sms.cgi, Email Account Name,E-posta Hesap Adı, Email Account added multiple times,E-posta Hesabı birden çok kez eklendi, @@ -965,7 +972,7 @@ Email Group,E-posta Grubu, Email Group List,E-posta Grubu Listesi, Email Group Member,Grup Üyesi e-posta, Email Login ID,E-posta Giriş Kimliği, -Email Queue,E-posta Kuyruk, +Email Queue,E-posta Kuyruğu, Email Queue Recipient,E-posta Kuyruk Alıcı, Email Queue records.,E-posta Kuyruk kayıtları., Email Reply Help,E-posta Yanıtı Yardım, @@ -1002,15 +1009,15 @@ Enable Two Factor Auth,İki Faktör Onayı Etkinleştir, Enabled email inbox for user {0},{0} kullanıcısı için e-posta gelen kutusu etkin, "Encryption key is invalid, Please check site_config.json","Şifreleme anahtarı geçersiz, lütfen site_config.json dosyasını kontrol edin.", End Date Field,Bitiş Tarihi Alanı, -End Date cannot be before Start Date!,"Bitiş Tarihi, Başlangıç Tarihi'nden önce olamaz!", -Endpoint URL,Bitiş noktası URL'si, -Energy Point Log,Enerji Noktası Günlüğü, -Energy Point Rule,Enerji Noktası Kuralı, -Energy Point Settings,Enerji Noktası Ayarları, -Energy Points,Enerji Noktaları, -Enter Email Recipient(s),Enter-posta Alıcı (lar), +End Date cannot be before Start Date!,"Bitiş Tarihi, Başlangıç Tarihi'nden önce olamaz!", +Endpoint URL,Bitiş noktası URL'si, +Energy Point Log,Enerji Puanı Günlüğü, +Energy Point Rule,Enerji Puanı Kuralı, +Energy Point Settings,Enerji Puanı Ayarları, +Energy Points,Enerji Puanları, +Enter Email Recipient(s),E-posta Alıcılarını Girin, Enter Form Type,Form Türü Girin, -"Enter default value fields (keys) and values. If you add multiple values for a field, the first one will be picked. These defaults are also used to set ""match"" permission rules. To see list of fields, go to ""Customize Form"".","Varsayılan değer alanları (tuşlar) ve değerlerini girin. Bir alan için birden fazla değer eklerseniz, ilki alınacak. Bu varsayılan ayrıca "maç" izni kuralları ayarlamak için kullanılır. Alanların listesini görmek için, "Özelleştir Formu" gidin.", +"Enter default value fields (keys) and values. If you add multiple values for a field, the first one will be picked. These defaults are also used to set ""match"" permission rules. To see list of fields, go to ""Customize Form"".","Varsayılan değer alanları (tuşlar) ve değerlerini girin. Bir alan için birden fazla değer eklerseniz, ilki alınacak. Bu varsayılan ayrıca 'maç' izni kuralları ayarlamak için kullanılır. Alanların listesini görmek için, 'Özelleştir Formu' gidin.", Enter folder name,Klasör adını girin, "Enter keys to enable login via Facebook, Google, GitHub.","Facebook, Google, GitHub üzerinden oturum açmayı etkinleştirme anahtarlarını girin.", Enter python module or select connector type,Python modülünü girin veya konektör türünü seçin, @@ -1050,11 +1057,11 @@ Expire Notification On,On Bildirimi Expire, Expires In,İçinde sona eriyor, Expiry time of QR Code Image Page,QR Kodu Resim Sayfa son kullanma süresi, Export All {0} rows?,Tüm {0} satırları dışa aktar?, -Export Custom Permissions,İhracat Özel İzinler, -Export Customizations,ihracat Özelleştirmeler, +Export Custom Permissions,Export Özel İzinler, +Export Customizations,Export Özelleştirmeleri, Export Data,Verileri Dışa Aktar, Export Data in CSV / Excel format.,Verileri CSV / Excel formatında dışa aktarın., -Export Report: {0},İhracat Raporu: {0}, +Export Report: {0},Export Raporu: {0}, Expose Recipients,Alıcılar Açığa, "Expression, Optional","İfade, Opsiyonel", Facebook,Facebook, @@ -1067,12 +1074,12 @@ Fetch From,From Get, Fetch If Empty,Boşsa Al, Fetch Images,Görüntüleri getir, Fetch attached images from document,Ekli görüntüleri dokümandan getir, -"Field ""route"" is mandatory for Web Views",Web İzleme alanları "rota" zorunlu, -"Field ""value"" is mandatory. Please specify value to be updated",Alan "değer" zorunludur. güncelleştirilmesi için değer belirtin, +"Field ""route"" is mandatory for Web Views",Web İzleme alanları 'rota' zorunlu, +"Field ""value"" is mandatory. Please specify value to be updated",Alan 'değer' zorunludur. güncelleştirilmesi için değer belirtin, Field Description,Alan Açıklama, Field Maps,Alan Haritaları, Field Type,Alan Türü, -"Field that represents the Workflow State of the transaction (if field is not present, a new hidden Custom Field will be created)","İş Akışı işlem Devleti (alan mevcut değilse, yeni bir gizli Özel Alan oluşturulur) temsil Field", +"Field that represents the Workflow State of the transaction (if field is not present, a new hidden Custom Field will be created)","İşlemin İş Akışı Durumunu temsil eden alan (alan yoksa yeni bir gizli Özel Alan oluşturulacaktır)", Field to Track,İzlenecek Alan, Field type cannot be changed for {0},{0} için alan türü değiştirilemiyor, Field {0} not found.,{0} alanı bulunamadı., @@ -1082,16 +1089,16 @@ Fieldname which will be the DocType for this link field.,Bu linki alan için Doc Fieldname {0} cannot have special characters like {1},Fieldname {0} gibi özel karakterleri olamaz {1}, Fieldname {0} conflicting with meta object,{0} alan adı meta nesne ile çakışıyor, Fields Multicheck,Alanlar Multicheck, -"Fields separated by comma (,) will be included in the ""Search By"" list of Search dialog box","Virgülle ayrılmış alanlar (,) dahil edilecektir Arama iletişim kutusunun listesinde "ile Arama"", -Fieldtype,FIELDTYPE, +"Fields separated by comma (,) will be included in the ""Search By"" list of Search dialog box","Virgülle ayrılmış alanlar (,) dahil edilecektir Arama iletişim kutusunun listesinde 'ile Arama'", +Fieldtype,ALANTIPI, Fieldtype cannot be changed from {0} to {1} in row {2},Alan Türleri {0} değiştirilemez {1} üste {2}, -File '{0}' not found,Dosya '{0}' bulunamadı, +File '{0}' not found,Dosya '{0}' bulunamadı, File Backup,Dosya Yedekleme, File Name,Dosya Adı, File Size,Dosya Boyutu, -File Type,Dosya tipi, +File Type,Dosya Tipi, File URL,Dosya URL'si, -File Upload,Dosya yükleme, +File Upload,Dosya Yükle, File Upload Disconnected. Please try again.,Dosya Yüklemesi kesildi. Lütfen tekrar deneyin., File Upload in Progress. Please try again in a few moments.,Dosya Yükleme Devam Ediyor. Lütfen birkaç dakika içinde tekrar deneyin., File backup is ready,Dosya yedeklemesi hazır, @@ -1100,8 +1107,8 @@ File size exceeded the maximum allowed size of {0} MB,Dosya boyutu {0} MB izin v File too big,Dosya çok büyük, File {0} does not exist,{0} yok Dosya, Files,Dosyalar, -Filter,filtre, -Filter Data,Verileri Filtreleme, +Filter,Filtrele, +Filter Data,Verileri Filtrele, Filter List,Filtre Listesi, Filter Meta,Meta Filtre, Filter Name,Filtre Adı, @@ -1109,27 +1116,27 @@ Filter Values,Filtre Değerleri, Filter must be a tuple or list (in a list),Filtre bir liste veya liste olmalıdır (bir listede), "Filter must have 4 values (doctype, fieldname, operator, value): {0}","Filtrenin 4 değeri olmalıdır (doctype, fieldname, operator, value): {0}", Filter...,Filtre ..., -"Filtered by ""{0}""",Tarafından Filtreli "{0}", -Filters Display,Filtreler Ekran, +"Filtered by ""{0}""","""{0}"" tarafından Filtreli", +Filters Display,Filtreler Ekranı, Filters JSON,JSON Filtreleri, Filters saved,Filtreler kaydedildi, Find {0} in {1},{0} Bul {1}, -First Level,İlk seviye, +First Level,İlk Seviye, First Success Message,İlk Başarı Mesajı, First Transaction,İlk işlem, First data column must be blank.,İlk veri sütunu boş olmalı., First set the name and save the record.,İlk önce ismi ayarlayın ve kaydı kaydedin., -Flag,bayrak, +Flag,Bayrak, Float,Float, -Float Precision,Float Precision, +Float Precision,Float Hassasiyeti, Fold,Kat, Fold can not be at the end of the form,Katlama formun sonundaki olamaz, Fold must come before a Section Break,Bir bölüm sonu önce gelmelidir Fold, Folder,Klasör, -Folder name should not include '/' (slash),Klasör adı '/' (eğik çizgi) içermemelidir., +Folder name should not include '/' (slash),Klasör adı '/' (eğik çizgi) içermemelidir., Folder {0} is not empty,Klasör {0} boş değil, Follow,Takip et, -Followed by,Bunu takiben, +Followed by,Takip eden, Following fields are missing:,Aşağıdaki alanlar eksik:, Following fields have missing values:,Aşağıdaki alanlar eksik değerler vardır:, Font,Yazı tipi, @@ -1148,15 +1155,15 @@ For example if you cancel and amend INV004 it will become a new document INV004- "For example: If you want to include the document ID, use {0}","Örneğin: Belge Kimliği eklemek istiyorsanız, kullanmak {0}", "For updating, you can update only selective columns.","Güncellenmesi için, sadece seçici sütunları güncelleyebilirsiniz.", For {0} at level {1} in {2} in row {3},Için {0} düzeyde {1} {2} tane üst üste {3}, -Force,Kuvvet, -Force Show,kuvvet göster, +Force,Zorla, +Force Show,Zorla göster, Forgot Password,Parolanızı mı unuttunuz, Forgot Password?,Parolanızı mı unuttunuz?, Form Customization,Form Özelleştirme, Form Settings,Form Ayarları, Format,Biçim, Format Data,Biçim Verileri, -Forward To Email Address,İleri E-posta Adresi, +Forward To Email Address,E-posta Adresine İlet, Fraction,Kesir, Fraction Units,Kesir Birimleri, Frames,Çerçeveler, @@ -1165,7 +1172,7 @@ Frappe Framework,Frappe Çerçeve, Friendly Title,Kullanıcı Dostu Başlık, From Date Field,Tarih Alanından, From Document Type,Belge Türünden, -From Full Name,Tam İsim, +From Full Name,Tam Adı, Full Page,Tam Sayfa, Fw: {0},İlt: {0}, GCalendar Sync ID,GCalendar Sync Kimliği, @@ -1187,7 +1194,7 @@ GitHub,GitHub, Give Review Points,İnceleme Puanı Ver, Global Unsubscribe,Küresel aboneliğini, Go to the document,Belgeye git, -Go to this URL after completing the form (only for Guest users),Formu tamamladıktan sonra bu URL'ye gidin (yalnızca Misafir kullanıcıları için), +Go to this URL after completing the form (only for Guest users),Formu tamamladıktan sonra bu URL'ye gidin (yalnızca Misafir kullanıcıları için), Go to {0},{0} adresine git, Go to {0} List,{0} Listeye Git, Go to {0} Page,{0} Sayfaya git, @@ -1196,7 +1203,7 @@ Google Analytics ID,Google Analytics Kimliği, Google Calendar ID,Google Takvim Kimliği, Google Font,Google Yazı Tipi, Google Services,Google Hizmetleri, -Grant Type,hibe Tipi, +Grant Type,Hibe Tipi, Group Name,Grup ismi, Group name cannot be empty.,Grup adı boş olamaz., Groups of DocTypes,Belgetürleri Grupları, @@ -1205,12 +1212,12 @@ HTML Editor,HTML Editör, "HTML Header, Robots and Redirects","HTML Üstbilgisi, Robotlar ve Yönlendirmeler", HTML for header section. Optional,Başlık bölüm için HTML. Opsiyonel, Half,Yarım, -Has Attachment,Ek Has, +Has Attachment,Eki Var, Has Attachments,Ekleri Var, Has Domain,Etki Alanı Var, -Has Role,Rol Has, +Has Role,Rolü var, Has Web View,Web Görünümü Has, -Have an account? Login,Hesabın var mı? Oturum aç, +Have an account? Login,"Hesabın var mı? Oturum aç", Header,Başlık, Header HTML,Başlık HTML, Header HTML set from attachment {0},{0} ekinden HTML başlık seti, @@ -1244,7 +1251,7 @@ Host,evsahibi, Hostname,Hostadı, "How should this currency be formatted? If not set, will use system defaults","Bu para birimi nasıl biçimlendirilmelidir? Ayarlanmamışsa, sistem varsayılanı kullanacaktır.", I found these: ,Bunları buldum:, -ID,İD, +ID,ID, ID (name) of the entity whose property is to be set,Özelliği ayarlanmalıdır varlık kimliği (adı), Icon will appear on the button,Simge düğmesi görünecektir, Identity Details,Kimlik Ayrıntıları, @@ -1254,21 +1261,21 @@ If Checked workflow status will not override status in list view,İşaretli iş If Owner,Sahibi ise, "If a Role does not have access at Level 0, then higher levels are meaningless.","Rol 0 düzeyinde erişimi yoksa, daha yüksek seviyeler anlamsızdır.", "If checked, all other workflows become inactive.","Eğer işaretli ise, diğer tüm iş akışları inaktif hale gelir.", -"If checked, this field will be not overwritten based on Fetch From if a value already exists.","İşaretlenirse, bir değer zaten mevcutsa, Get From From'a dayalı olarak bu alanın üzerine yazılmaz.", +"If checked, this field will be not overwritten based on Fetch From if a value already exists.","İşaretlenirse, bir değer zaten mevcutsa, Get From From'a dayalı olarak bu alanın üzerine yazılmaz.", "If checked, users will not see the Confirm Access dialog.","kontrol, kullanıcılar Onayla Erişim iletişimi bir daha görmezsiniz.", "If disabled, this role will be removed from all users.","devre dışı ise, bu rol tüm kullanıcılar kaldırılır.", -"If enabled, user can login from any IP Address using Two Factor Auth, this can also be set for all users in System Settings","Etkinleştirildiğinde, kullanıcı İki Faktör Kimlik Doğrulaması kullanarak herhangi bir IP Adresinden giriş yapabilir, bu, Sistem Ayarları'ndaki tüm kullanıcılar için de ayarlanabilir.", +"If enabled, user can login from any IP Address using Two Factor Auth, this can also be set for all users in System Settings","Etkinleştirildiğinde, kullanıcı İki Faktör Kimlik Doğrulaması kullanarak herhangi bir IP Adresinden giriş yapabilir, bu, Sistem Ayarları'ndaki tüm kullanıcılar için de ayarlanabilir.", "If enabled, all users can login from any IP Address using Two Factor Auth. This can also be set only for specific user(s) in User Page","Etkinleştirilirse, tüm kullanıcılar Two Factor Auth kullanarak herhangi bir IP adresinden giriş yapabilirler. Bu, yalnızca Kullanıcı Sayfasındaki belirli kullanıcılar için de ayarlanabilir", "If enabled, changes to the document are tracked and shown in timeline","Etkinleştirilirse, belgede yapılan değişiklikler izlenir ve zaman çizelgesinde gösterilir.", "If enabled, document views are tracked, this can happen multiple times","Etkinleştirilirse, belge görünümleri izlenir, bu birkaç kez olabilir", "If enabled, the document is marked as seen, the first time a user opens it","Etkinleştirilirse, belge, kullanıcı ilk kez açtığında göründüğü gibi işaretlenir", -"If enabled, the password strength will be enforced based on the Minimum Password Score value. A value of 2 being medium strong and 4 being very strong.","Etkinleştirilirse, şifre kuvveti Minimum Şifre Puanı değerine dayanarak zorlanır. 2'lik bir değer orta derecede güçlü ve 4'ü çok güçlü.", -"If enabled, users who login from Restricted IP Address, won't be prompted for Two Factor Auth","Etkinleştirilmişse, Kısıtlı IP Adresi'nden giriş yapan kullanıcılar, İki Faktör Auth için istenmez", +"If enabled, the password strength will be enforced based on the Minimum Password Score value. A value of 2 being medium strong and 4 being very strong.","Etkinleştirilirse, şifre kuvveti Minimum Şifre Puanı değerine dayanarak zorlanır. 2'lik bir değer orta derecede güçlü ve 4'ü çok güçlü.", +"If enabled, users who login from Restricted IP Address, won't be prompted for Two Factor Auth","Etkinleştirilmişse, Kısıtlı IP Adresi'nden giriş yapan kullanıcılar, İki Faktör Auth için istenmez", "If enabled, users will be notified every time they login. If not enabled, users will only be notified once.","Etkinleştirilirse, kullanıcıların her eriştiklerinde bilgilendirilirler. Etkinleştirilmezse, kullanıcılar yalnızca bir kez bilgilendirilir.", If non standard port (e.g. 587),standart olmayan bağlantı noktası (örneğin 587), "If non standard port (e.g. 587). If on Google Cloud, try port 2525.",Standart olmayan port ise (örn. 587). Google Cloud’ta 2525 numaralı bağlantı noktasını deneyin., "If not set, the currency precision will depend on number format","Ayarlanmazsa, para birimi hassaslığı sayı formatına bağlı olacaktır", -If the condition is satisfied user will be rewarded with the points. eg. doc.status == 'Closed'\n,Koşul yerine getirilirse kullanıcı puanla ödüllendirilecektir. Örneğin. doc.status == 'Kapalı', +If the condition is satisfied user will be rewarded with the points. eg. doc.status == 'Closed'\n,Koşul yerine getirilirse kullanıcı puanla ödüllendirilecektir. Örneğin. doc.status == 'Kapalı', "If the user has any role checked, then the user becomes a ""System User"". ""System User"" has access to the desktop","Eğer bir kullanıcı için herhangi bir Rol seçildiyse, kullanıcı ""Sistem Kullanıcısı"" olur. ""Sistem Kullanıcısı"" masaüstüne erişim hakkına sahiptir.", "If these instructions where not helpful, please add in your suggestions on GitHub Issues.","Bu talimatlar burada yararlı değilse, GitHub konular üzerinde önerileri lütfen ekleyin.", "If this is checked, rows with valid data will be imported and invalid rows will be dumped into a new file for you to import later.","Bu işaretlenirse, geçerli verilere sahip satırlar içe aktarılır ve daha sonra içe aktarmanız için geçersiz satırlar yeni bir dosyaya dökülür.", @@ -1294,14 +1301,14 @@ Image field must be a valid fieldname,Resim Alamı geçerli bir alan adı olmal Image field must be of type Attach Image,"Resim Alanı ""Resim Ekle"" cinsinden bir alan olmalıdır", Images,Görüntüler, Implicit,üstü kapalı, -Import,İçe aktar, +Import,İçeri Aktar, Import Email From,E-postayı İçe Aktar, Import Status,İçe Aktarma Durumu, Import Subscribers,Aboneleri İçe Aktar, Import Zip,Zip Al, In Filter,Filtre, In Global Search,Küresel Ara, -In Grid View,Grid View, +In Grid View,Grid Görünüm, In Hours,Saatleri, In List View,Liste Görünümü, In Preview,Önizlemede, @@ -1325,27 +1332,27 @@ Info:,Bilgi:, Initial Sync Count,İlk Eşitleme Sayısı, InnoDB,InnoDB, Insert Above,Yukarı Ekle, -Insert After,Sonra ekle, +Insert After,Sonra Ekle, Insert After cannot be set as {0},Olarak ayarlanamaz sonra yerleştirin {0}, -"Insert After field '{0}' mentioned in Custom Field '{1}', with label '{2}', does not exist","alan '{0}' Özel Alan belirtilen sonra yerleştirin '{1}' etiketi ile '{2}', yok", -Insert Below,Aşağıda takın, +"Insert After field '{0}' mentioned in Custom Field '{1}', with label '{2}', does not exist","alan '{0}' Özel Alan belirtilen sonra yerleştirin '{1}' etiketi ile '{2}', yok", +Insert Below,Aşağı Ekle, Insert Column Before {0},{0} önündeki Sütun Ekle, Insert Style,Stil ekleme, Insert new records,Yeni kayıt ekle, Instructions Emailed,E-postayla gönderilen talimatlar, Insufficient Permission for {0},{0} için yetersiz izin, Int,Int, -Integration Request,entegrasyon Talebi, +Integration Request,Entegrasyon Talebi, Integration Request Service,Entegrasyon Talep Hizmet, -Integration Type,entegrasyon Tipi, -Integrations,Entegrasyonları, +Integration Type,Entegrasyon Tipi, +Integrations,Entegrasyonlar, Integrations can use this field to set email delivery status,Entegrasyonlar e-posta dağıtım durumunu ayarlamak için bu alanı kullanabilirsiniz, Internal Server Error,İç Sunucu Hatası, Internal record of document shares,Belge hisse İç rekor, Introduce your company to the website visitor.,Web sitesi ziyaretçi için şirketinizi tanıtın, Introductory information for the Contact Us Page,İletişim Sayfası için gerekli bilgiler, Invalid,Geçersiz, -"Invalid ""depends_on"" expression",Geçersiz "depends_on" ifadesi, +"Invalid ""depends_on"" expression",Geçersiz 'depends_on' ifadesi, Invalid Access Key ID or Secret Access Key.,Geçersiz Erişim Tuş Kimliği veya Gizli Erişim Tuşu., Invalid CSV Format,Geçersiz CSV Biçimi, Invalid Home Page,Geçersiz Ana Sayfa, @@ -1364,7 +1371,7 @@ Invalid Token,Geçersiz Jetonu, Invalid User Name or Support Password. Please rectify and try again.,Geçersiz Kullanıcı Adı veya ޞifre Destek. Lütfen Düzeltin ve tekrar deneyin., Invalid column,Geçersiz sütun, Invalid field name {0},Geçersiz alan adı {0}, -Invalid fieldname '{0}' in autoname,autoname geçersiz AlanAdı '{0}', +Invalid fieldname '{0}' in autoname,autoname geçersiz AlanAdı '{0}', Invalid file path: {0},Geçersiz dosya yolu: {0}, Invalid login or password,Geçersiz giriş ya da şifre, Invalid module path,Geçersiz modül yolu, @@ -1373,29 +1380,30 @@ Invalid payment gateway credentials,Geçersiz ödeme ağ geçidi kimlik bilgiler Invalid recipient address,Geçersiz alıcı adresi, Invalid {0} condition,Geçersiz {0} durumu, Inverse,Ters, -Is,Mı, -Is Attachments Folder,Ekler Klasör mı, +Installed Apps,Kurulan Uygulamalar, +Is,Is, +Is Attachments Folder,Ekler Klasörü mü, Is Child Table,Alt tablo mu, -Is Custom Field,Özel Alan mi, +Is Custom Field,Özel Alan mı, Is First Startup,İlk Başlangıç mı, -Is Folder,Klasör mı, -Is Global,Küresel mi, +Is Folder,Klasör mü, +Is Global,Genel mi, Is Globally Pinned,Global olarak sabitlenmiş, -Is Home Folder,Home Folder mı, +Is Home Folder,Ana Klasör mü, Is Mandatory Field,Zorunlu Alan mi, Is Pinned,Sabitlendi, Is Primary Contact,Birincil İrtibat, Is Private,Özel mi, -Is Published Field,Alan Yayın mi, -Is Published Field must be a valid fieldname,Saha gerekir Yayın geçerli bir AlanAdı olmak, +Is Published Field,Alan Yayınlandı mı, +Is Published Field must be a valid fieldname,Yayınlandı Alan geçerli bir alan adı olmalıdır, Is Single,Tek mi, Is Spam,Spam mı, Is Standard,Standart mı, -Is Submittable,Submittable mi, +Is Submittable,Gönderilebilir mi, Is Table,Tablo mu, Is Your Company Address,Firmanız Adresi, It is risky to delete this file: {0}. Please contact your System Manager.,Bu dosyayı silmek için riskli: {0}. Sistem Yöneticisi irtibata geçiniz., -Item cannot be added to its own descendants,"Ürün, kendi soyundan ilave edilemez", +Item cannot be added to its own descendents,"Ürün, kendi soyundan ilave edilemez", JS,JS, JSON,JSON, JavaScript Format: frappe.query_reports['REPORTNAME'] = {},JavaScript Format: frappe.query_reports ['ReportName'] = {}, @@ -1403,19 +1411,19 @@ Javascript to append to the head section of the page.,Javascript sayfasının ba Jinja,Jinja, John Doe,John Doe, Kanban,Kanban, -Kanban Board Column,Kanban Kurulu Sütun, -Kanban Board Name,Kanban Bölüm İsmi, +Kanban Board Column,Kanboard Sütun, +Kanban Board Name,Kanboard Adı, Karma,Karma, Keep track of all update feeds,Tüm güncelleme akışlarını takip et, Keeps track of all communications,Tüm iletişimlerin kaydını tutar, Key,Anahtar, -Knowledge Base,Bilgi tabanı, -Knowledge Base Contributor,Bilgi Bankası Katılımcı, -Knowledge Base Editor,Bilgi Bankası Editör, +Knowledge Base,Bilgi Bankası, +Knowledge Base Contributor,Bilgi Bankası Katılımcısı, +Knowledge Base Editor,Bilgi Bankası Editörü, LDAP Email Field,LDAP E-posta Alan, LDAP First Name Field,LDAP Ad Alanı, LDAP Not Installed,LDAP Yüklü Değil, -LDAP Search String,LDAP Arama Dize, +LDAP Search String,LDAP Arama Dizesi, "LDAP Search String needs to end with a placeholder, eg sAMAccountName={0}","LDAP Arama Dizgisinin bir yer tutucu ile bitmesi gerekiyor, örn. SAMAccountName = {0}", LDAP Security,LDAP Güvenliği, LDAP Server Url,LDAP Sunucusu URL, @@ -1434,18 +1442,18 @@ Last Known Versions,Son Bilinen Sürümleri, Last Login,Son Giriş, Last Message,Son Mesaj, Last Modified By,Son Değiştiren, -Last Modified Date,Son Değiştirilen Tarih, -Last Modified On,Son olarak Modifiye, -Last Month,Geçen ay, +Last Modified Date,Son Değiştirilme Tarihi, +Last Modified On,Son Değiştirilme, +Last Month,Geçen Ay, Last Point Allocation Date,Son Nokta Tahsis Tarihi, -Last Quarter,Son çeyrek, +Last Quarter,Son Çeyrek, Last Synced On,Son Senkronize Açık, -Last Updated By,Son Güncelleme tarafından, -Last Updated On,Son olarak Güncelleme, -Last User,Son kullanıcı, -Last Week,Geçen hafta, -Last Year,Geçen yıl, -Last synced {0},Son senkronizasyon {0}, +Last Updated By,Son Güncelleyen, +Last Updated On,Son Güncelleme Tarihi, +Last User,Son Kullanıcı, +Last Week,Geçen Hafta, +Last Year,Geçen Yıl, +Last synced {0},Son eşitleme {0}, Leave a Comment,Yorum Yap, Leave blank to repeat always,Her zaman tekrarlamak için boş bırakın, Leave this conversation,Bu konuşmayı bırakın, @@ -1455,50 +1463,52 @@ Length of {0} should be between 1 and 1000,{0} uzunluğu 1 ile 1000 arasında ol Let's avoid repeated words and characters,tekrarlanan kelimeleri ve karakterleri önlemek edelim, Let's prepare the system for first use.,Ilk kullanım için sistemi hazırlamak edelim., Letter,Mektup, -Letter Head Based On,Temelli Mektup Başlığı, -Letter Head Image,Letter Head Görüntüsü, +Letter Head Based On,Mektup Başlığına göre, +Letter Head Image,Antet Resmi, Letter Head Name,Antet Adı, -Letter Head in HTML,HTML Mektubu Başkanı, +Letter Head in HTML,HTML Mektup Başlığı, Level Name,Seviye Adı, Liked,Beğendim, -Liked By,By Beğendim, -Liked by {0},Tarafından Beğendim {0}, +Liked By,Beğenen, +Liked by {0},{0} tarafından Beğenildi, Likes,Beğeniler, Limit Number of DB Backups,DB Yedeklemelerinin Sınırı Sayısı, Line,Hat, -Link DocType,bağlantı DocType, +Link DocType,Bağlantı DocType, Link Expired,Bağlantı Süresi Doldu, -Link Name,bağlantı Adı, -Link Title,link Title, +Link Name,Bağlantı Adı, +Link Title,Bağlantı Başlığı, "Link that is the website home page. Standard Links (index, login, products, blog, about, contact)","Bu web sitesi ana sayfası bağlantı. Standart Linkler (indeks, giriş, ürünleri, blog, hakkında, iletişim)", Link to the page you want to open. Leave blank if you want to make it a group parent.,Açmak istediğiniz sayfaya bağlantı. Eğer bir grup ebeveyn yapmak istiyorsanız boş bırakın., Linked,Bağlantılı, -Linked With,Ile Bağlantılı, +Linked With,İle Bağlantılı, Linked with {0},{0} ile bağlantılı, Links,Bağlantılar, List,Liste, +List View,Liste Görünümü, List Filter,Liste Filtresi, -List View Setting,Liste Görünümü Ayarı, +List View Setting,Liste Görünüm Ayarı, List a document type,Bir belge türü Liste, -"List as [{""label"": _(""Jobs""), ""route"":""jobs""}]","[{: _ ( "İşler"), "rota": "işler" "etiketi"}] olarak Listesi", +"List as [{""label"": _(""Jobs""), ""route"":""jobs""}]","[{: _ ( 'İşler'), 'rota': 'işler' 'etiketi'}] olarak Listesi", List of backups available for download,İndirilebilir yedeklerin listesi, List of patches executed,Yamalar listesi idam, List of themes for Website.,Web sitesi için temalar listesi., -Load Balancing,Yük dengeleme, -Loading,Yükleme, -Local DocType,Yerel DocType, +Load Balancing,Yük Dengeleme, +Loading,Yükleniyor, +Loading versions...,Sürümler Yükleniyor... +Local DocType,Yerel Belge Türü, Local Fieldname,Yerel Alan Adı, Local Primary Key,Yerel İlköğretim Anahtarı, -Locals,Yerliler, +Locals,Yerel, Log Details,Günlük Ayrıntıları, Log of Scheduler Errors,Zamanlayıcı Hatalar Giriş, Log of error during requests.,Istekleri sırasında hata yapın., Log of error on automated events (scheduler).,Otomatik olaylar (zamanlayıcı) hakkında hata açın., Logged Out,Çıkış yapıldı, Logged in as Guest or Administrator,Misafir veya Yönetici olarak oturum, -Login,Giriş, -Login After,Sonra yap, -Login Before,Önce Giriş, +Login,Oturum aç, +Login After,Sonra Giriş Yap, +Login Before,Önce Giriş yap, Login Id is required,Giriş Kimliği gerekli, Login Required,Giriş Gerekli, Login Verification Code from {},{} Adresinden Giriş Doğrulama Kodu, @@ -1514,8 +1524,8 @@ Looks like something is wrong with this site's Paypal configuration.,Görünüş Looks like something is wrong with this site's payment gateway configuration. No payment has been made.,Görünüşe göre bu sitenin ödeme ağ geçidi yapılandırması ile ilgili bir sorun var. Herhangi bir ödeme yapılmadı., "Looks like something went wrong during the transaction. Since we haven't confirmed the payment, Paypal will automatically refund you this amount. If it doesn't, please send us an email and mention the Correlation ID: {0}.","bir şey işlemi sırasında yanlış gitti gibi görünüyor. Biz ödeme onaylandıktan değil çünkü, Paypal otomatik olarak bu miktarı iade edecektir. Aksi takdirde, bize bir e-posta göndermek ve Korelasyon kimliğini belirtiniz: {0}.", Madam,madam, -Main Section,Ana bölüm, -"Make ""name"" searchable in Global Search",Küresel Ara arama yapılabilsin "ad", +Main Section,Ana Bölüm, +"Make ""name"" searchable in Global Search",Küresel Ara arama yapılabilsin 'ad', Make use of longer keyboard patterns,uzun klavye desen yararlanın, Manage Third Party Apps,Üçüncü Taraf Uygulamalarını Yönetin, Mandatory Information missing:,Eksik zorunlu bilgiler:, @@ -1541,7 +1551,7 @@ Max width for type Currency is 100px in row {0},Para için maksimum genişlik 10 Maximum Attachment Limit for this record reached.,Bu kayıt için maksimum Ek Sınırı ulaştı., Maximum {0} rows allowed,Maksimum {0} satıra izin verildi, "Meaning of Submit, Cancel, Amend",Arasında Gönder İptal Amend Anlamı, -Mention transaction completion page URL,işlem tamamlama sayfası URL'sini Mansiyon, +Mention transaction completion page URL,işlem tamamlama sayfası URL'sini Mansiyon, Mentions,Mansiyonlar, Menu,Menü, Merchant ID,Tüccar kimliği, @@ -1560,14 +1570,15 @@ Migration ID Field,Taşıma Kimliği Alanı, Milestone,Aşama, Milestone Tracker,Kilometre Taşı İzleyici, Minimum Password Score,Minimum Şifre Puanı, -Miss,bayan, +Miss,Bayan, Missing Fields,Eksik Alanları, Missing parameter Kanban Board Name,Kanban Board Name eksik parametre, Missing parameters for login,Giriş için eksik parametreler, Models (building blocks) of the Application,Uygulama Modelleri (yapı taşları), Modified By,Tarafından tasarlandı, -Module,modül, -Module Def,Modül Def, +Module,Modül, +Module Profile,Modül Profili, +Module Def,Modül Tanımları, Module Name,Modül Adı, Module Not Found,Modül Bulunamadı, Module Path,Modül Yolu, @@ -1577,7 +1588,7 @@ Monospace,Monospace, More articles on {0},Daha makaleler {0}, More content for the bottom of the page.,Sayfanın altında daha fazla içerik., Most Used,En çok kullanılan, -Move To,Taşınmak, +Move To,Taşı, Move To Trash,Çöp kutusuna taşıyın, Move to Row Number,Satır Numarasına Taşı, Mr,Bay, @@ -1606,9 +1617,9 @@ New Chat,Yeni sohbet, New Comment on {0}: {1},{0} Üzerine Yeni Yorum: {1}, New Connection,Yeni Bağlantı, New Custom Print Format,Yeni Özel Baskı Biçimi, -New Email,yeni e-posta, +New Email,Yeni E-posta, New Email Account,Yeni E-posta Hesabı, -New Event,Yeni etkinlik, +New Event,Yeni Etkinlik, New Folder,Yeni dosya, New Kanban Board,Yeni Kanban Kurulu, New Message from Website Contact Page,Web sitesi İletişim Sayfasından Yeni İleti, @@ -1625,7 +1636,7 @@ New value to be set,Ayarlanacak Yeni değer, New {0},Yeni {0}, New {} releases for the following apps are available,Aşağıdaki uygulamalar için yeni {} sürümler kullanıma sunuldu, Newsletter Email Group,Bülten E-posta Grubu, -Newsletter Manager,Bülten Müdürü, +Newsletter Manager,Bülten Yöneticisi, Newsletter has already been sent,Bülten zaten gönderildi, "Newsletters to contacts, leads.","İrtibatlara, müşterilere bülten", Next Action Email Template,Sonraki Eylem E-posta Şablonu, @@ -1636,7 +1647,7 @@ Next State,Next State, Next Sync Token,Sonraki Senkronizasyon Jetonu, Next actions,Sonraki eylemler, No Active Sessions,Etkin Oturum Yok, -No Copy,No Copy, +No Copy,Kopya yok, No Email Account,E-posta Yok Hesabı, No Email Accounts Assigned,E-posta Hesabı Atanan, No Emails,hiçbir e-postalar, @@ -1646,14 +1657,14 @@ No Permissions set for this criteria.,No Permissions set for this criteria., No Preview,Önizleme yok, No Preview Available,Önizleme Yok, No Printer is Available.,Yazıcı Yok., -No Results,No Sonuçları, -No Tags,hiçbir Etiketler, +No Results,Sonuç yok, +No Tags,Etiket yok, No alerts for today,Bugün için uyarı yok, -No comments yet,henüz yorum yok, +No comments yet,Yorum yok, No comments yet. Start a new discussion.,Henüz yorum yok. Yeni bir tartışma başlat., No data found in the file. Please reattach the new file with data.,Dosyada veri bulunamadı. Lütfen yeni dosyayı veri ile yeniden bağlayın., No document found for given filters,Verilen filtreler için hiçbir doküman bulunamadı, -"No fields found that can be used as a Kanban Column. Use the Customize Form to add a Custom Field of type ""Select"".",Kanban sütunu olarak kullanılabilecek alan bulunamadı. "Seç" tipi bir Özel Alan eklemek için Özelleştir Formunu kullanın., +"No fields found that can be used as a Kanban Column. Use the Customize Form to add a Custom Field of type ""Select"".",Kanban sütunu olarak kullanılabilecek alan bulunamadı. 'Seç' tipi bir Özel Alan eklemek için Özelleştir Formunu kullanın., No file attached,Ekli dosya yok, No further records,Başka bir kayıt, No matching records. Search something new,Eşleşen kayıtları. Yeni bir şey ara, @@ -1670,19 +1681,19 @@ No records present in {0},{0} içinde hiç kayıt yok, No records tagged.,Hiç bir kayıt etiketlenmedi., No template found at path: {0},Yolda bulunamadı şablon: {0}, No {0} found,Gösterilecek {0} bulunamadı, -No {0} mail,Hayır {0} posta, +No {0} mail,Hiç {0} posta yok, No {0} permission,{0} izni yok, None: End of Workflow,Hiçbiri: İş Akışı sonu, Not Allowed: Disabled User,İzin Verilmedi: Engelli Kullanıcı, Not Ancestors Of,Ataları değil, Not Descendants Of,Torunları değil, -Not Equals,Eşit değildir, -Not In,Değil ise, +Not Equals,Eşit değil, +Not In,İçinde geçen değil, Not Linked to any record,Herhangi bir rekorla bağlantılı değil, Not Published,Yayınlandı değil, Not Saved,Kaydedilmedi, Not Seen,Görmedim, -Not Sent,Gönderilen Değil, +Not Sent,Gönderilmedi, Not Set,Ayarlanmadı, Not a valid Comma Separated Value (CSV File),Geçerli bir Virgülle Ayrılmış Değer (CSV Dosyası), Not a valid User Image.,Geçerli bir Kullanıcı Resmi değil., @@ -1690,8 +1701,8 @@ Not a valid Workflow Action,Geçerli bir İş Akışı Eylemi değil, Not a valid user,Geçerli bir kullanıcı, Not a zip file,Değil bir zip dosyası, Not allowed for {0}: {1},{0} için izin verilmiyor: {1}, -"You are not allowed to access {0} because it is linked to {1} '{2}' in row {3}, field {4}","{0} erişmek için izniniz yok, çünkü {3} satırında {1} '{2}' bağlıdır, alan {4}", -You are not allowed to access this {0} record because it is linked to {1} '{2}' in field {3},"Bu {0} kaydına erişmek için izniniz yok, çünkü alan {3} {1} '{2}' bağlıdır", +Not allowed for {0}: {1} in Row {2}. Restricted field: {3},{2} satırında {0}: {1} için izin verilmez. Sınırlı alan: {3}, +Not allowed for {0}: {1}. Restricted field: {2},{0} için izin verilmiyor: {1}. Sınırlı alan: {2}, Not allowed to Import,İçe Aktarıma izin verilmiyor, Not allowed to change {0} after submission,Sunulmasından sonra {0} değiştirmek için izin verilmez, Not allowed to print cancelled documents,İptal edilmiş dokümanlar yazdırılamaz, @@ -1702,13 +1713,13 @@ Not in Developer Mode! Set in site_config.json or make 'Custom' DocType.,Değil Note Seen By,By Görülme Not, Note:,Not:, Note: By default emails for failed backups are sent.,"Not: Varsayılan olarak, başarısız yedeklemeler için e-postalar gönderilir.", -Note: Changing the Page Name will break previous URL to this page.,Not: Sayfa Adını değiştirmek önceki URL'yi bu sayfaya ayıracaktır., +Note: Changing the Page Name will break previous URL to this page.,Not: Sayfa Adını değiştirmek önceki URL'yi bu sayfaya ayıracaktır., "Note: For best results, images must be of the same size and width must be greater than height.",Not: En iyi sonucu elde etmek için görüntüler aynı boyutta olmalı ve genişlik yükseklikten büyük olmalıdır., Note: Multiple sessions will be allowed in case of mobile device,Not: Birden fazla seans mobil cihazın durumunda izin verilecek, Nothing to show,Gösterilecek bir şey yok, Nothing to update,Güncellenecek bir şey yok, -Notification,tebliğ, -Notification Recipient,Bildirim Alıcı, +Notification,Bildirim, +Notification Recipient,Bildirim Alıcısı, Notification Tones,Bildirim Zilleri, Notifications,Bildirimler, Notifications and bulk mails will be sent from this outgoing server.,Bildirimler ve toplu postalar bu giden sunucudan gönderilir., @@ -1719,7 +1730,7 @@ Notify users with a popup when they log in,oturum açtıklarında bir pop-up ile Number Format,Sayı Biçimi, Number of Backups,Yedeklemeler sayısı, Number of DB Backups,DB Yedekleme Sayısı, -Number of DB backups cannot be less than 1,DB yedeklerinin sayısı 1'den az olamaz, +Number of DB backups cannot be less than 1,DB yedeklerinin sayısı 1'den az olamaz, Number of columns for a field in a Grid (Total Columns in a grid should be less than 11),bir kılavuz bir alan için sütun sayısı (bir ızgarada Toplam Sütunlar az 11 olmalıdır), Number of columns for a field in a List View or a Grid (Total Columns should be less than 11),Bir Liste Görünümü veya bir kılavuz bir alan için sütun sayısı (Toplam Sütunlar az 11 den olmalıdır), OAuth Authorization Code,OAuth Yetki Kodu, @@ -1738,7 +1749,7 @@ Older backups will be automatically deleted,Eski yedekleri otomatik olarak silin "On {0}, {1} wrote:","{0} üzerinde, {1} yazdı:", "Once submitted, submittable documents cannot be changed. They can only be Cancelled and Amended.",Gönderildikten sonra gönderilebilir belgeler değiştirilemez. Sadece İptal Edilebilir ve Değiştirilebilirler., "Once you have set this, the users will only be able access documents (eg. Blog Post) where the link exists (eg. Blogger).","Bunu ayarladıktan sonra, kullanıcılar sadece mümkün erişim belgeler (örn. olacak Bağlantı var Blog Post) (örn. Blogger).", -One Last Step,One Last Step, +One Last Step,Son Bir Adım, One Time Password (OTP) Registration Code from {},Bir Zamanlı Parola (OTP) Kayıt Kodu {}, Only 200 inserts allowed in one request,Sadece 200 ekler tek isteği izin, Only Administrator can delete Email Queue,Sadece yönetici E-posta Kuyruğu silebilirsiniz, @@ -1757,11 +1768,11 @@ Open Link,Açık Bağlantı, Open Source Applications for the Web,Web Açık Kaynak Uygulamaları, Open Translation,Çeviri Açık, Open a dialog with mandatory fields to create a new record quickly,Hızlı bir şekilde yeni bir kayıt oluşturmak için zorunlu alanlarla bir iletişim kutusu açın, -Open a module or tool,Bir modül veya aracı açmak, +Open a module or tool,Bir modül veya aracı aç, Open your authentication app on your mobile phone.,Kimlik doğrulama uygulamanızı cep telefonunuzdan açın., -Open {0},Open {0}, +Open {0},{0} Aç, Opened,Açılmış, -Operator must be one of {0},Operatör {0} 'dan biri olmalı, +Operator must be one of {0},Operatör {0} 'dan biri olmalı, Option 1,Seçenek 1, Option 2,Seçenek 2, Option 3,Seçenek 3, @@ -1771,7 +1782,7 @@ Options 'Dynamic Link' type of field must point to another Link Field with optio Options Help,Yardım Seçenekleri, Options for select. Each option on a new line.,Select için seçenekler. Yeni bir satırda her seçenek., Options not set for link field {0},Seçenekleri link alanına ayarlı değil {0}, -Or login with,Ya ile giriş, +Or login with,veya bununla giriş yap, Order,Sipariş, Org History,Org Tarihçe, Org History Heading,Org Tarih Başlık, @@ -1785,7 +1796,7 @@ PDF Page Size,PDF Sayfa Boyutu, PDF Settings,PDF Ayarları, PDF generation failed,PDF oluşturma başarısız oldu, PDF generation failed because of broken image links,PDF oluşturma nedeniyle kırık görüntü bağlantıları başarısız, -"PDF printing via ""Raw Print"" is not yet supported. Please remove the printer mapping in Printer Settings and try again.","Raw Print" ile PDF yazdırma henüz desteklenmiyor. Lütfen Yazıcı Ayarlarını Yazıcı Ayarları'ndan kaldırın ve tekrar deneyin., +"PDF printing via ""Raw Print"" is not yet supported. Please remove the printer mapping in Printer Settings and try again.",'Raw Print' ile PDF yazdırma henüz desteklenmiyor. Lütfen Yazıcı Ayarlarını Yazıcı Ayarları'ndan kaldırın ve tekrar deneyin., Page HTML,Sayfa HTML, Page Length,Sayfa Uzunluğu, Page Name,Sayfa Adı, @@ -1804,8 +1815,8 @@ Partial Success,Kısmi Başarı, Partially Successful,Kısmen Başarılı, Participants,Katılımcılar, Passive,Pasif, -Password Reset,Parola sıfırlama, -Password Updated,Şifre Güncelleme, +Password Reset,Şifreyi Sıfırla, +Password Updated,Şifre Güncellendi, Password for Base DN,Baz DN için şifre, Password is required or select Awaiting Password,Şifre gerekli veya Bekleniyor Parola seçmektir, Password not found,Şifre bulunamadı, @@ -1863,12 +1874,12 @@ Please do not change the template headings.,Şablon başlıkları değiştirmek Please duplicate this to make changes,Değişikliğin uygulanması için Lütfen bu öğeyi çoğaltın, Please enable developer mode to create new connection,Lütfen yeni bağlantı oluşturmak için geliştirici modunu etkinleştirin, Please ensure that your profile has an email address,Profil E-posta adresi olduğundan emin olun, -Please enter Access Token URL,Lütfen Erişim Simgesi URL'si girin, -Please enter Authorize URL,Lütfen URL'yi Yetkilendirin girin, -Please enter Base URL,Lütfen Temel URL'yi girin, +Please enter Access Token URL,Lütfen Erişim Simgesi URL'si girin, +Please enter Authorize URL,Lütfen URL'yi Yetkilendirin girin, +Please enter Base URL,Lütfen Temel URL'yi girin, Please enter Client ID before social login is enabled,Sosyal giriş etkinleştirilmeden önce lütfen Müşteri Kimliğini girin, Please enter Client Secret before social login is enabled,Sosyal giriş etkinleştirilmeden önce Müşteri Sırrını giriniz, -Please enter Redirect URL,Lütfen Yönlendirme URL'sini girin, +Please enter Redirect URL,Lütfen Yönlendirme URL'sini girin, Please enter the password,şifrenizi giriniz, Please enter valid mobile nos,Lütfen Geçerli bir cep telefonu numarası giriniz, Please enter values for App Access Key and App Secret Key,Uygulama Erişim Anahtarı ve App Gizli Key değerlerini girin, @@ -1881,23 +1892,23 @@ Please save the document before assignment,Atama öncesi belgeyi saklayınız, Please save the document before removing assignment,Atama çıkarmadan önce belgeyi saklayınız, Please save the report first,İlk raporu kaydetmek Lütfen, Please select DocType first,İlk DOCTYPE seçiniz, -Please select Entity Type first,Lütfen önce Varlık Türü'nü seçin, +Please select Entity Type first,Lütfen önce Varlık Türü'nü seçin, Please select Minimum Password Score,Lütfen Minimum Şifre Puanını seçin, Please select a Amount Field.,Bir Tutar alanı seçiniz., Please select a file or url,Bir dosya veya url seçiniz, Please select a new name to rename,Lütfen yeniden adlandırmak için yeni bir ad seçin, Please select a valid csv file with data,Veri içeren geçerli bir csv dosyası seçiniz, -Please select another payment method. PayPal does not support transactions in currency '{0}',başka bir ödeme yöntemi seçin. PayPal '{0}' para biriminde yapılan işlemleri desteklemez, -Please select another payment method. Razorpay does not support transactions in currency '{0}',başka bir ödeme yöntemi seçin. Razorpay '{0}' para biriminde yapılan işlemleri desteklemez, +Please select another payment method. PayPal does not support transactions in currency '{0}',başka bir ödeme yöntemi seçin. PayPal '{0}' para biriminde yapılan işlemleri desteklemez, +Please select another payment method. Razorpay does not support transactions in currency '{0}',başka bir ödeme yöntemi seçin. Razorpay '{0}' para biriminde yapılan işlemleri desteklemez, Please select atleast 1 column from {0} to sort/group,{0} sıralamak / gruptan en az 1 sütun seçin, Please select document type first.,Lütfen önce doküman tipini seçiniz., -Please select the Document Type.,Lütfen Belge Türü'nü seçin., -Please set Base URL in Social Login Key for Frappe,Lütfen Frappe için Sosyal Oturum Açma Anahtarında Temel URL'yi ayarlayın, +Please select the Document Type.,Lütfen Belge Türü'nü seçin., +Please set Base URL in Social Login Key for Frappe,Lütfen Frappe için Sosyal Oturum Açma Anahtarında Temel URL'yi ayarlayın, Please set Dropbox access keys in your site config,Lütfen site yapılandırmanızda Dropbox erişim anahtarı ayarlayınız, Please set a printer mapping for this print format in the Printer Settings,Lütfen bu yazdırma formatı için Yazıcı Ayarlarında bir yazıcı eşlemesi ayarlayın, Please set filters,Filtreleri ayarlayın Lütfen, Please set filters value in Report Filter table.,Rapor Filtresi tabloda filtreler değerini ayarlayın., -"Please setup SMS before setting it as an authentication method, via SMS Settings","Lütfen, SMS Ayarları aracılığıyla bir kimlik doğrulama yöntemi olarak ayarlamadan önce SMS'i kurun.", +"Please setup SMS before setting it as an authentication method, via SMS Settings","Lütfen, SMS Ayarları aracılığıyla bir kimlik doğrulama yöntemi olarak ayarlamadan önce SMS'i kurun.", Please setup a message first,Lütfen önce bir mesaj kurun, Please specify which date field must be checked,Kontrol edilmesi gereken tarih alanı belirtiniz, Please specify which value field must be checked,Değer alanı kontrol edilmesi gerektiği belirtiniz, @@ -1919,7 +1930,7 @@ Posts by {0},Tarafından Mesaj {0}, Posts filed under {0},Filed under Mesajlar {0}, Precision,Hassas, Precision should be between 1 and 6,Hassas 1 ile 6 arasında olmalıdır, -Predictable substitutions like '@' instead of 'a' don't help very much.,Öngörülebilir gibi değiştirmeler '@' yerine '' çok fazla yardımcı olmamaktadır., +Predictable substitutions like '@' instead of 'a' don't help very much.,Öngörülebilir gibi değiştirmeler '@' yerine '' çok fazla yardımcı olmamaktadır., Preferred Billing Address,Tercih edilen Fatura Adresi, Preferred Shipping Address,Tercih edilen Teslimat Adresi, Prepared Report,Hazırlanmış Rapor, @@ -1939,8 +1950,8 @@ Print Format {0} is disabled,Baskı Biçimi {0} devre dışı, Print Hide,Gizle Yazdır, Print Hide If No Value,Baskı gizle Hayır Değer, Print Sent to the printer!,Yazdır Yazıcıya gönderildi!, -Print Server,Yazdırma Sunucusu, -Print Style,Yazdırma Stili, +Print Server,Baskı Sunucusu, +Print Style,Baskı Stili, Print Style Name,Baskı Stili Adı, Print Style Preview,Baskı Önizleme Stil, Print Width,Baskı Genişliği, @@ -1956,7 +1967,7 @@ Private and public Notes.,Özel ve kamu Notları., ProTip: Add Reference: {{ reference_doctype }} {{ reference_name }} to send document reference,ProTip: Ekle Reference: {{ reference_doctype }} {{ reference_name }} göndermek için doküman referansı, Processing,İşleme, Processing...,İşleme..., -Prof,profesör, +Prof,Prof, Progress,İlerleme, Property Setter,Özellik Belirleyici, Property Setter overrides a standard DocType or Field property,Mülkiyet Setter standart DOCTYPE veya Saha özelliğini geçersiz kılar, @@ -1969,12 +1980,12 @@ Published On,Yayınlandı, Pull,Çek, Pull Failed,Çekin Başarısız Oldu, Pull Insert,Çekme Uç, -Pull Update,Güncelleme Çek, +Pull Update,Pull Update, Push,it, Push Delete,Sil tuşuna basın, Push Failed,İtem Başarısız Oldu, -Push Insert,Ekle'yi itin, -Push Update,Güncelleştir'e Bas, +Push Insert,Ekle'yi itin, +Push Update,Güncelleştir'e Bas, Python Module,Python Modülü, Pyver,Pyver, QR Code,QR kod, @@ -2001,7 +2012,7 @@ Read,Okundu, Read Only,Salt Okunur, Read by Recipient,Alıcıya Göre Oku, Read by Recipient On,Alıcının Açık Oku, -Rebuild,yeniden inşa etmek, +Rebuild,Rebuild, Receiver Parameter,Alıcı Parametre, Recent years are easy to guess.,Son yıllarda tahmin etmek kolaydır., Recipient,Alıcı, @@ -2010,7 +2021,7 @@ Record does not exist,Kaydı yok, Records for following doctypes will be filtered,Aşağıdaki doktrinler için kayıtlar filtrelenecek, Redirect To,To yönlendir, Redirect URI Bound To Auth Code,URI Kimlik Doğrulama Kodu için Bound REDIRECT_PATH, -Redirect URIs,URI'ları yönlendir, +Redirect URIs,URI'ları yönlendir, Redis cache server not running. Please contact Administrator / Tech support,Redis önbellek sunucusu çalışmıyor. Yönetici / Teknik desteğe başvurun, Ref DocType,Ref DocType, Ref Report DocType,Ref Rapor DocType, @@ -2023,10 +2034,10 @@ Register OAuth Client App,OAuth İstemci App Kayıt Ol, Registered but disabled,Tescil edilmiş ancak engelli, Relapsed,Nüks, Relapses,Relapslar, -Relink,yeniden bağlama, +Relink,Yeniden bağla, Relink Communication,Yeniden Bağla Haberleşme, -Relinked,yeniden bağlandı, -Reload,Yeniden yükle, +Relinked,Yeniden bağlandı, +Reload,Yeniden Yükle, Remember Last Selected Value,Son Seçilmiş Değer hatırla, Remote,uzak, Remote Fieldname,Uzak Alan Adı, @@ -2047,8 +2058,8 @@ Repeat On,Tekrar açık, Repeat Till,Till tekrarlayın, Repeat on Day,Günde tekrarlayın, Repeat this Event,Bu olay tekrarlayın, -"Repeats like ""aaa"" are easy to guess","Aaa" gibi tekrarlar tahmin etmek kolaydır, -"Repeats like ""abcabcabc"" are only slightly harder to guess than ""abc""","Abcabcabc" sadece biraz zor "abc" den tahmin gibi tekrarlar, +"Repeats like ""aaa"" are easy to guess",'aaa' gibi tekrarları tahmin etmek kolaydır, +"Repeats like ""abcabcabc"" are only slightly harder to guess than ""abc""",'Abcabcabc' sadece biraz zor 'abc' den tahmin gibi tekrarlar, Reply,Cevapla, Reply All,Hepsini cevapla, Report End Time,Bitiş Zamanı Bildir, @@ -2071,42 +2082,42 @@ Request URL,URL isteğinde bulun, Require Trusted Certificate,Güvenilir Sertifika İste, Res: {0},Res: {0}, Reset OTP Secret,OTP Anahtarını Sıfırla, -Reset Password,Parola Sıfırlama, -Reset Password Key,Şifre Key Reset, -Reset Permissions for {0}?,{0} için İzinlerini Sıfırla?, -Reset to defaults,Varsayılan sıfırla, +Reset Password,Şifreyi Sıfırla, +Reset Password Key,Şifre Anahtarını Sıfırla, +Reset Permissions for {0}?,{0} için İzinleri Sıfırla?, +Reset to defaults,Varsayılana Sıfırla, Reset your password,Şifrenizi sıfırlayın, -Response Type,tepki Türü, -Restore,Restore, +Response Type,Yanıt Türü, +Restore,Geri yükle, Restore Original Permissions,Orijinal izinler Restore, Restore or permanently delete a document.,Bir belgeyi geri yükleyin veya kalıcı olarak silin., Restore to default settings?,Varsayılan ayarları geri yükle?, -Restored,restore, +Restored,Geri yüklendi, Restrict IP,IP sınırla, Restrict To Domain,Etki Alanıyla Sınırla, Restrict user for specific document,Belirli bir belge için kullanıcıyı kısıtla, Restrict user from this IP address only. Multiple IP addresses can be added by separating with commas. Also accepts partial IP addresses like (111.111.111),Sadece bu IP adresinden kullanıcı kısıtlayın. Çoklu IP adresleri virgül ile ayırarak eklenebilir. Ayrıca gibi kısmi IP adresleri (111.111.111) kabul, Resume Sending,gönderme Özgeçmiş, -Retake,geri almak, +Retake,Geri al, Retry,Tekrar dene, Return to the Verification screen and enter the code displayed by your authentication app,Doğrulama ekranına geri dönün ve kimlik doğrulama uygulamanız tarafından görüntülenen kodu girin, Reverse Icon Color,Simge Renk Ters, -Revert,dönmek, +Revert,Eski haline çevir, Revert Of,Geri Dön, Reverted,Geri alındı, Review Level,İnceleme Seviyesi, Review Levels,Seviyeleri gözden geçir, Review Points,Değerlendirme Noktaları, -Reviews,yorumlar, -Revoke,geri almak, +Reviews,Yorumlar, +Revoke,Geri al, Revoked,iptal, Rich Text,Zengin Metin, Robots.txt,robots.txt, Role Name,Rol Adı, -Role Permission for Page and Report,Page ve Raporu için Rol İzni, -Role Permissions,Rol İzinler, +Role Permission for Page and Report,Sayfa ve Rapor için Rol İzni, +Role Permissions,Rol İzinleri, Role Profile,Rol Profili, -Role and Level,Rolü ve Seviye, +Role and Level,Rol ve Seviye, Roles,Roller, Roles Assigned,Atanan Rolleri, Roles can be set for users from their User page.,Roller kendi Kullanıcı sayfasından kullanıcılar için ayarlanabilir., @@ -2127,49 +2138,49 @@ Rows Added,Satırlar eklendi, Rows Removed,Satırlar kaldırıldı, Rule,Kural, Rule Name,Kural ismi, -Rules defining transition of state in the workflow.,Iş akışında devletin geçişi tanımlayan kurallar., -"Rules for how states are transitions, like next state and which role is allowed to change state etc.","Devletlerin yanında devlet ve hangi rolü gibi geçişler, ne için kurallar vb durumunu değiştirmek için izin verilir", -Run,koş, +Rules defining transition of state in the workflow.,Iş akışında durumun geçişini tanımlayan kurallar., +"Rules for how states are transitions, like next state and which role is allowed to change state etc.","Durumların nasıl geçiş olduğuna ilişkin kurallar, bir sonraki durum gibi ve hangi rolün durumu değiştirmesine izin verildiği vb.", +Run,Çalıştır, Run scheduled jobs only if checked,Kontrol yalnızca zamanlanmış işlerini, S3 Backup Settings,S3 Yedekleme Ayarları, -S3 Backup complete!,S3 Yedekleme tamamlandı!, +S3 Backup complete!,S3 Yedeklemeyi tamamla!, SMS,SMS, -SMS Gateway URL,SMS Anageçit Adresi, +SMS Gateway URL,SMS Gateway Adresi, SMS Parameter,SMS Parametresi, SMS Settings,SMS Ayarları, SMS sent to following numbers: {0},SMS aşağıdaki numaralardan gönderilen: {0}, SMTP Server,SMTP Sunucusu, SMTP Settings for outgoing emails,Giden e-postalar için SMTP Ayarları, -"SQL Conditions. Example: status=""Open""",SQL Koşulları. Örnek: status = "Aç", +"SQL Conditions. Example: status=""Open""",SQL Koşulları. Örnek: status = 'Aç', SSL/TLS Mode,SSL / TLS Modu, -Salesforce,Satış ekibi, +Salesforce,Salesforce, Same Field is entered more than once,Aynı Alana birden çok kez girildi, -Save API Secret: ,API Gizli Kaydet:, -Save As,Farklı kaydet, +Save API Secret: ,API Secret Kaydet: , +Save As,Farklı Kaydet, Save Filter,Filtreyi Kaydet, Save Report,Raporu Kaydet, Save filters,Filtreleri kaydet, Saving,Kaydediliyor, -Saving...,Tasarruf ..., -Scan the QR Code and enter the resulting code displayed.,QR Code'u tarayın ve görüntülenen sonuç kodunu girin., -Scopes,kapsamları, +Saving...,Kaydediliyor..., +Scan the QR Code and enter the resulting code displayed.,QR Code'u tarayın ve görüntülenen sonuç kodunu girin., +Scopes,Kapsamlar, Script,Script, -Script Report,Senaryo Raporu, -Script or Query reports,Yazı veya sorgu raporları, -Script to attach to all web pages.,Senaryo tüm web sayfalarına eklemek için., -Search Fields,Arama Alanları, -Search Help,Yardım İste, +Script Report,Script Raporu, +Script or Query reports,Script veya Sorgu raporları, +Script to attach to all web pages.,Script tüm web sayfalarına eklemek için., +Search Fields,Alanları Ara, +Search Help,Yardım Ara, Search field {0} is not valid,Arama alanı {0} geçerli değil, -Search for '{0}','{0}' için ara, +Search for '{0}','{0}' için ara, Search for anything,herhangi bir şey için ara, Search in a document type,Bir belge türü ara, Search or Create a New Chat,Yeni Bir Mesaj Arama veya Yeni Bir Sohbet Oluşturma, -Search or type a command,Bir komutu ara veya yazın, -Search...,Arama..., -Searching,Arama, -Searching ...,Arama ..., +Search or type a command (Ctrl + G),Bir komut arayın veya yazın (Ctrl + G), +Search...,Arama yap..., +Searching,Arama yapılıyor, +Searching ...,Arama yapılıyor..., Section Break,Bölüm Sonu, -Section Heading,bölüm başlığı, +Section Heading,Bölüm başlığı, Security,Güvenlik, Security Settings,Güvenlik Ayarları, See all past reports.,Geçmiş tüm raporlara bakın., @@ -2181,11 +2192,11 @@ Seems Publishable Key or Secret Key is wrong !!!,Yayınlanabilir Anahtar veya Gi Seems token you are using is invalid!,Kullandığınız simge geçersiz görünüyor!, Seen,Görülme, Seen By,Tarafından görüldü, -Seen By Table,Tablo By Görülme, +Seen By Table,Tabloya göre Görülme, Select Attachments,Ekleri seç, Select Child Table,Çocuk Masası Seç, -Select Column,Sütunu seçin, -Select Columns,Seçin Sütunlar, +Select Column,Sütun Seç, +Select Columns,Sütunları Seç, Select Document Type,Belge Türü Seçin, Select Document Type or Role to start.,Başlatmak için Belge Türü veya Rol seçin., Select Document Types to set which User Permissions are used to limit access.,Seç Belge Türleri erişimi sınırlamak için kullanıldığı Kullanıcı İzinleri ayarlamak için., @@ -2195,11 +2206,11 @@ Select Language...,Dil Seçin..., Select Languages,seç Diller, Select Module,seç Modülü, Select Print Format,Baskı Biçimi Seç, -Select Print Format to Edit,Edit Baskı Biçimi seçin, +Select Print Format to Edit,Düzenlemek için Baskı Biçimi seçin, Select Role,Rol Seçin, -Select Table Columns for {0},Için Tablo Seç Sütunlar {0}, -Select Your Region,Bölgenizi Seçiniz, -Select a Brand Image first.,Önce bir Marka Resim seçin., +Select Table Columns for {0},{0} için Tablo Sütunları Seç, +Select Your Region,Bölgenizi Seçin, +Select a Brand Image first.,Önce bir Marka Resmi seçin., Select a DocType to make a new format,Yeni bir format yapmak için bir DOCTYPE seçin, Select a chat to start messaging.,Mesajlaşmayı başlatmak için bir sohbet seçin., Select a group node first.,İlk grup düğümünü seçin., @@ -2213,7 +2224,7 @@ Select the label after which you want to insert new field.,Select the label afte Select {0},Seçin {0}, Self approval is not allowed,Kendi onayına izin verilmez, Send After,Sonra Gönder, -Send Alert On,Uyarı göndermek, +Send Alert On,Uyarı gönder, Send Email Alert,E-posta Uyarısı Gönder, Send Email Print Attachments as PDF (Recommended),PDF olarak Email Print Ekler Gönder (Önerilen), Send Email for Successful Backup,Başarılı Yedekleme İçin E-posta Gönder, @@ -2229,7 +2240,7 @@ Send alert if this field's value changes,Uyarı gönder Bu alanın değeri deği Send an email reminder in the morning,Sabah bir hatırlatma e-postası gönder, Send days before or after the reference date,Önce veya referans tarihinden sonra gün Gönder, Send enquiries to this email address,Bu e-posta adresine soruşturma gönder, -Send me a copy,Bana bir kopya gönder, +Send me a copy,Bana bir kopyasını gönder, Send only if there is any data,"herhangi bir veri varsa, sadece Gönder", Send unsubscribe message in email,e-postadaki aboneliği iptal mesaj gönder, Sender,Gönderici, @@ -2238,27 +2249,27 @@ Sendgrid,Sendgrid, Sent Read Receipt,Gönderilen okundu bilgisi, Sent or Received,Gönderilmiş veya Alınmış, Sent/Received Email,Gönderilen / Alınan E-posta, -Server IP,Sunucu IP'si, +Server IP,Sunucu IP'si, Session Expired,Oturum süresi doldu, Session Expiry,Oturum kapanacak, -Session Expiry Mobile,Oturum Vade Mobil, -Session Expiry in Hours e.g. 06:00,Oturum kapanacak saat sonra, -Session Expiry must be in format {0},Oturum Vade formatında olmalıdır {0}, +Session Expiry Mobile,Oturum Sonu Mobil, +Session Expiry in Hours e.g. 06:00,Saat Cinsinden Oturum Bitişi Örn. 06:00, +Session Expiry must be in format {0},Oturum Bitişi {0} formatında olmalıdır, Session Start Failed,Oturum Başlatma Başarısız Oldu, -Set Banner from Image,Image Banner Set, +Set Banner from Image,Image Banner Ayarla, Set Chart,Grafiği Ayarla, Set Filters,Filtreleri Ayarla, Set New Password,Yeni Şifre ayarla, Set Number of Backups,Yedekler Set Sayısı, Set Only Once,Sadece bir kez ayarlama, -Set Password,Şifre seç, +Set Password,Şifre Seç, Set Permissions,İzinleri Ayarla, Set Permissions on Document Types and Roles,Belge Türleri ve Rol üzerinde İzinleri Ayarlama, Set Property After Alert,Uyarının Ardından Mülkü Ayarla, -Set Quantity,Set Miktarı, -Set Role For,İçin Set Rol, +Set Quantity,Miktarı Ayarla, +Set Role For,Rolü Ayarla, Set User Permissions,Kullanıcı Yetkilerini Belirle, -Set Value,Değeri ayarlayın, +Set Value,Değer Ayarla, Set custom roles for page and report,sayfa ve rapor için ayarlanan özel rolleri, "Set default format, page size, print style etc.","Set varsayılan biçim, sayfa boyutu, baskı tarzı vs", Set non-standard precision for a Float or Currency field,Bir Bolluk veya Para Birimi alanı için Set standart dışı hassas, @@ -2271,20 +2282,20 @@ Settings for Contact Us Page,Bize Sayfa Ayarları, Settings for Contact Us Page.,İletişim Sayfası Ayarları, Settings for OAuth Provider,OAuth Sağlayıcısı Ayarları, Settings for the About Us Page,Hakkımızda Sayfa Ayarları, -Setup Auto Email,Kurulum Otomatik e-posta, -Setup Complete,Kurulum tamamlandı, +Setup Auto Email,Otomatik E-posta Kurulumu, +Setup Complete,Kurulumu Tamamla, Setup Notifications based on various criteria.,Çeşitli kriterlere göre Kurulum Bildirimleri., Setup Reports to be emailed at regular intervals,Kurulum Raporları düzenli aralıklarla gönderilecektir edilecek, "Setup of top navigation bar, footer and logo.","Üst gezinti çubuğu, altbilgi ve logo Kur.", -Share,Pay, +Share,Paylaş, Share URL,URL paylaş, -Share With,Ile paylaş, +Share With,ile paylaş, Share this document with,Bu belgeyi paylaş, Share {0} with,{0} öğesini şunla paylaş, -Shared,paylaşılan, -Shared With,Paylaşıldı, +Shared,Paylaşıldı, +Shared With,Bununla Paylaşıldı, Shared with everyone,Herkesle paylaştı, -Shared with {0},Paylaşılan {0}, +Shared with {0},{0} ile Paylaşıldı, Shop,Mağaza, Short keyboard patterns are easy to guess,Kısa klavye desenleri tahmin etmek kolaydır, Show Attachments,Ekleri Göster, @@ -2299,26 +2310,26 @@ Show Report,Raporu göster, Show Section Headings,Göster Bölüm Başlıkları, Show Sidebar,göster Kenar Çubuğu, Show Title,Göster Başlığı, -Show Totals,Toplamları göster, -Show Weekends,Hafta sonlarını göster, -Show all Versions,Tüm Sürümleri göster, +Show Totals,Toplamları Göster, +Show Weekends,Hafta sonlarını Göster, +Show all Versions,Tüm Sürümleri Göster, Show as Grid,Izgara Olarak Göster, -Show as cc,cc olarak göster, +Show as cc,cc olarak Göster, Show failed jobs,Göster başarısız işler, -Show in Module Section,Modül Bölüm göster, +Show in Module Section,Modül Bölüm Göster, Show in filter,Filtrede göster, -Show more details,Daha fazla ayrıntı göster, -Show only errors,Yalnızca hataları göster, -"Show title in browser window as ""Prefix - title""",Olarak tarayıcı penceresinde göster başlığı "Önek - title", -Showing only Numeric fields from Report,Yalnızca Rapor'dan sayısal alanlar gösteriliyor, +Show more details,Daha fazla ayrıntı Göster, +Show only errors,Yalnızca hataları Göster, +"Show title in browser window as ""Prefix - title""",Olarak tarayıcı penceresinde göster başlığı 'Önek - title', +Showing only Numeric fields from Report,Yalnızca Rapor'dan sayısal alanlar gösteriliyor, Sidebar Items,Kenar çubuğu Öğeler, Sidebar Settings,Kenar çubuğu ayarları, Sidebar and Comments,Kenar çubuğu ve Yorumlar, Sign Up,Kaydol, Sign Up is disabled,Yeni kayıtlar devredışı, Signature,İmza, -"Simple Python Expression, Example: Status in (""Closed"", ""Cancelled"")","Basit Python İfadesi, Örnek: Durumdaki ("Kapalı", "İptal edildi")", -"Simple Python Expression, Example: status == 'Open' and type == 'Bug'","Basit Python İfadesi, Örnek: status == 'Aç' ve == 'Hata' yazın", +"Simple Python Expression, Example: Status in (""Closed"", ""Cancelled"")","Basit Python İfadesi, Örnek: Durumdaki ('Kapalı', 'İptal edildi')", +"Simple Python Expression, Example: status == 'Open' and type == 'Bug'","Basit Python İfadesi, Örnek: status == 'Aç' ve == 'Hata' yazın", Simultaneous Sessions,Eşzamanlı Oturumlar, Single DocTypes cannot be customized.,Tek Doküman Tipleri özelleştirilemez., Single Post (article).,Single Post (makale)., @@ -2329,14 +2340,14 @@ Skype,Skype, Slack,Gevşek, Slack Channel,Slack Kanal, Slack Webhook Error,Slack Webhook Hatası, -Slack Webhook URL,Slack Webhook URL'si, +Slack Webhook URL,Slack Webhook URL'si, Slack Webhooks for internal integration,Dahili entegrasyon için gevşek Webhooks, Slideshow Items,Slayt Ürünler, Slideshow Name,Slayt İsmi, Slideshow like display for the website,Web sitesi için ekran gibi Slayt, Small Text,Küçük Metin, Smallest Currency Fraction Value,Küçük Döviz Kesir Değeri, -Smallest circulating fraction unit (coin). For e.g. 1 cent for USD and it should be entered as 0.01,Küçük dolaşan kesir birimi (sikke). O 0.01 olarak girilmelidir ve ABD Doları için örneğin 1 kuruş için, +Smallest circulating fraction (coin). For e.g. 1 cent for USD and it should be entered as 0.01,Küçük dolaşan kesir birimi (sikke). O 0.01 olarak girilmelidir ve ABD Doları için örneğin 1 kuruş için, Snapshot View,Anlık Görünüm, Social,Sosyal, Social Login Key,Sosyal Giriş Anahtarı, @@ -2359,7 +2370,7 @@ Source Text,Kaynak Metin, Spam,İstenmeyen e, SparkPost,SparkPost, Special Characters are not allowed,Özel Karakterler izin verilmez, -"Standard DocType cannot have default print format, use Customize Form","Standart DocType varsayılan yazdırma biçimine sahip olamaz, Formu Özelleştir'i kullanın", +"Standard DocType cannot have default print format, use Customize Form","Standart DocType varsayılan yazdırma biçimine sahip olamaz, Formu Özelleştir'i kullanın", Standard Print Format cannot be updated,Standart Baskı Biçimi güncellenen olamaz, Standard Print Style cannot be changed. Please duplicate to edit.,Standart Baskı Stili değiştirilemez. Düzenlemek için lütfen çoğaltın., Standard Reports,Standart Raporlar, @@ -2375,8 +2386,8 @@ StartTLS,StartTLS, Started,Başlatılan, Starting Frappe ...,Frappe başlıyor ..., Starts on,Başlıyor, -States,Devletler, -"States for workflow (e.g. Draft, Approved, Cancelled).","Iş akışı için Devletler (örneğin Taslak, Onaylı, İptal).", +States,Durumlar, +"States for workflow (e.g. Draft, Approved, Cancelled).","Iş akışı için Durumlar (örneğin Taslak, Onaylı, İptal).", Static Parameters,Statik Parametreleri, Stats based on last month's performance (from {0} to {1}),Geçen ayın performansına göre istatistikler ({0} - {1} arası), Stats based on last week's performance (from {0} to {1}),Geçen haftaki performansa dayalı istatistikler ({0} - {1} arası), @@ -2396,9 +2407,9 @@ Subdomain,Subdomain, Subject Field,Konu Alanı, Submit after importing,İçe aktarmadan sonra gönderme, Submit an Issue,Bir Sorun Gönder, -Submit this document to confirm,onaylamak için bu belge göndermek, +Submit this document to confirm,Onaylamak için bu belgeyi gönder, Submit {0} documents?,{0} dokümanı gönderilsin mi?, -Submitting {0},{0} gönderiliyor, +Submiting {0},{0} gönderiliyor, Submitted Document cannot be converted back to draft. Transition row {0},Ekleyen Belge taslak geri dönüştürülemez. Geçiş satır {0}, Submitting,Tanzim Ediliyor, Subscription Notification,Abonelik Bildirimi, @@ -2425,6 +2436,16 @@ System Page,Sistem Sayfası, System Settings,Sistem Ayarları, System User,Sistem Kullanıcısı, System and Website Users,Sistemi ve Web Sitesi Kullanıcıları, +Let's Set Up Some Masters,Biraz Ana Verileri Ayarlayalım, +"Company, Item, Customer, Supplier, Navigation Help, Data Import, Letter Head, Quotation","Şirket, Ürün, Müşteri, Tedarikçi, Navigasyon Yardımı, Veri İçe Aktarma, Antet, Fiyat Teklifi", +Set Up a Company,Şirket Kurulumu, +How to Navigate in ERPNext,ERPNext'te Nasıl Gezinilir, +Import Data from Spreadsheet,Elektronik Tablodan Veri Al, +Manage Items,Öğeleri Yönet, +Manage Customers,Müşterileri Yönetin, +Manage Suppliers,Tedarikçileri Yönetin, +Create your first Quotation,İlk Teklifinizi oluşturun, +Setup Your Letterhead,Antetli Kağıdınızı Hazırlayın, Table,Tablo, Table Field,Tablo Alanı, Table HTML,Tablo HTML, @@ -2449,7 +2470,7 @@ Thank you for your email,E-postanız için teşekkür ederiz, Thank you for your interest in subscribing to our updates,Güncellemeler için abone olduğunuzdan dolayı teşekkür ederiz, Thank you for your message,Mesajınız için teşekkür ederiz, The CSV format is case sensitive,CSV biçimi büyük / küçük harf duyarlıdır, -The Condition '{0}' is invalid,Durum '{0}' geçersiz, +The Condition '{0}' is invalid,Durum '{0}' geçersiz, The First User: You,İlk Kullanıcı: Sen, "The application has been updated to a new version, please refresh this page","Uygulama yeni bir sürüme güncellendi, lütfen bu sayfayı yenileyin", The attachments could not be correctly linked to the new document,Ekler yeni belgeye doğru şekilde bağlanılamıyor, @@ -2462,7 +2483,7 @@ The resource you are looking for is not available,Aradığınız kaynak mevcut d The system provides many pre-defined roles. You can add new roles to set finer permissions.,Sistem birçok önceden tanımlı roller sağlar. Sen ince izinlerini ayarlamak için yeni roller ekleyebilir., The user from this field will be rewarded points,Bu alandaki kullanıcı ödüllendirilecek puanlar olacak, Theme,Tema, -Theme URL,Tema URL'si, +Theme URL,Tema URL'si, There can be only one Fold in a form,Bir formda tek bir kat olabilir, There is an error in your Address Template {0},Adres Şablon bir hata var {0}, There is no data to be exported,Dışa aktarılacak veri yok, @@ -2488,7 +2509,7 @@ This email is autogenerated,Bu e-posta otomatik olarak oluşturuldu, This email was sent to {0},Bu e-posta gönderildi {0}, This email was sent to {0} and copied to {1},Bu e-posta {0} gönderilecek ve kopyalanan {1}, This feature is brand new and still experimental,Bu özellik hala yepyeni ve deneysel, -This field will appear only if the fieldname defined here has value OR the rules are true (examples): \nmyfield\neval:doc.myfield=='My Value'\neval:doc.age>18,"Burada tanımlanan AlanAdı değeri vardır VEYA kurallar gerçek (örnekler) yalnızca, bu alanı görünür: myField eval: doc.myfield == 'Benim Değer' eval: doc.age> 18", +"This field will appear only if the fieldname defined here has value OR the rules are true (examples): \nmyfield\neval:doc.myfield=='My Value'\neval:doc.age>18","Burada tanımlanan AlanAdı değeri vardır VEYA kurallar gerçek (örnekler) yalnızca, bu alanı görünür: myField eval: doc.myfield == 'Benim Değer' eval: doc.age> 18", This form does not have any input,Bu formda herhangi bir giriş yapılmamış, This form has been modified after you have loaded it,Bunu yükledikten sonra Bu form modifiye edilmiştir, This format is used if country specific format is not found,Ülkeye özgü format bulunamazsa bu format kullanılır, @@ -2563,7 +2584,7 @@ Track Email Status,E-posta Durumunu İzle, Track Field,Parça alanı, Track Seen,Görüldüğünü takip et, Track Views,İzlenme Sayısı, -"Track if your email has been opened by the recipient.\n
\nNote: If you're sending to multiple recipients, even if 1 recipient reads the email, it'll be considered ""Opened""","E-postanız alıcı tarafından açılmışsa izleyin.
Not: Birden fazla alıcıya gönderiyorsanız, 1 alıcı e-postayı okuyor olsa bile, "Açıldı" sayılır.", +"Track if your email has been opened by the recipient.\n
\nNote: If you're sending to multiple recipients, even if 1 recipient reads the email, it'll be considered ""Opened""","E-postanız alıcı tarafından açılmışsa izleyin.
Not: Birden fazla alıcıya gönderiyorsanız, 1 alıcı e-postayı okuyor olsa bile, 'Açıldı' sayılır.", Track milestones for any document,Herhangi bir belge için kilometre taşlarını izleyin, Transaction Hash,İşlem Hash, Transaction Log,İşlem Günlüğü, @@ -2579,7 +2600,7 @@ Trash,Çöp, Tree,ağaç, Trigger Method,tetik Yöntemi, Trigger Name,Tetikleyici Adı, -"Trigger on valid methods like ""before_insert"", ""after_update"", etc (will depend on the DocType selected)",""Before_insert", "after_update", vb gibi geçerli yöntemler hakkında Tetik (seçilen DocType bağlıdır)", +"Trigger on valid methods like ""before_insert"", ""after_update"", etc (will depend on the DocType selected)","'Before_insert', 'after_update', vb gibi geçerli yöntemler hakkında Tetik (seçilen DocType bağlıdır)", Try to avoid repeated words and characters,tekrarlanan kelimeleri ve karakterleri önlemek için deneyin, Try to use a longer keyboard pattern with more turns,Daha fazla dönüşler ile daha uzun klavye desen kullanmayı deneyin, Two Factor Authentication,İki Faktörlü Kimlik Doğrulama, @@ -2592,7 +2613,7 @@ UIDVALIDITY,UIDVALIDITY, UNSEEN,GÖRÜLMEMESİNİN, UPPER CASE,BÜYÜK HARF, "URIs for receiving authorization code once the user allows access, as well as failure responses. Typically a REST endpoint exposed by the Client App.\n
e.g. http://hostname//api/method/frappe.www.login.login_via_facebook",kullanıcı erişim sağlar bir kez yetki kodu alma yanı sıra başarısızlık yanıtları için URI'ları. Tipik REST uç Müşteri App tarafından gözler önüne serdi.
örneğin http: //hostname//api/method/frappe.www.login.login_via_facebook, -URLs,URL'ler, +URLs,URL'ler, Unable to find attachment {0},İlgili ek {0} bulunamadı, Unable to load camera.,Kamera yüklenemedi., Unable to load: {0},Yüklenemiyor: {0}, @@ -2611,39 +2632,39 @@ Unknown User,Bilinmeyen kullanıcı, "Unknown file encoding. Tried utf-8, windows-1250, windows-1252.","Bilinmeyen dosya kodlama. Denenmiş utf-8, windows-1250, windows-1252.", Unread,Okunmamış, Unread Notification Sent,Gönderilen Okunmamış Bildirimi, -Unselect All,Unselect Tüm, -Unshared,paylaşılmayan, +Unselect All,Tüm Seçimi Kaldır, +Unshared,Paylaşılmayan, Unsubscribe,Aboneliği Kaldır, -Unsubscribe Method,Aboneliği iptal Yöntemi, -Unsubscribe Param,Aboneliği iptal Param, +Unsubscribe Method,Abonelik İptal Yöntemi, +Unsubscribe Param,Abonelik İptal Parametresi, Unsupported File Format,Desteklenmeyen Dosya Biçimi, Unzip,Dosyaları Ayıkla, -Unzipped {0} files,Sıkıştırılmış {0} dosya, -Unzipping files...,Dosyaların açılması ..., +Unzipped {0} files,{0} dosya sıkıştırıldı, +Unzipping files...,Dosyalar ayıklanıyor..., Upcoming Events for Today,Bugün için Gelecek Etkinlikler, -Update Field,Alanı güncelle, +Update Field,Alanı Güncelle, Update Translations,Çevirileri Güncelle, Update Value,Değer Güncelle, Update many values at one time.,aynı anda çok sayıda değerleri güncelleştirmek., Update records,Kayıtları güncelle, -Updated,Güncellenmiş, +Updated,Güncellendi, Updated successfully,Başarıyla güncellendi, Updated {0}: {1},Güncellendi {0}: {1}, Updating,Güncelleniyor, Updating {0},{0} güncelleniyor, Upload Failed,Yükleme başarısız, -Uploaded To Dropbox,Dropbox'a Yüklendi, +Uploaded To Dropbox,Dropbox'a Yüklendi, Use ASCII encoding for password,Şifre için ASCII kodlamasını kullan, Use Different Email Login ID,Farklı E-posta Oturum Açma Kimliğini Kullan, -Use IMAP,Kullanım IMAP, -Use POST,POST'u kullanın, -Use SSL,SSL kullan, -Use TLS,TLS kullanın, -"Use a few words, avoid common phrases.","ortak sözcük önlemek, birkaç kelime kullanın.", +Use IMAP,IMAP Kullan, +Use POST,POST'u Kullan, +Use SSL,SSL Kullan, +Use TLS,TLS Kullan, +"Use a few words, avoid common phrases.","Birkaç kelime kullanın, yaygın ifadelerden kaçının.", Use of sub-query or function is restricted,Alt sorgu veya işlev kullanımı kısıtlıdır, Use socketio to upload file,Dosya yüklemek için socketio kullanın, Use this fieldname to generate title,Başlığı oluşturmak için bu alan adı kullanın, -User '{0}' already has the role '{1}',Kullanıcı '{0}' zaten bir role sahip '{1}', +User '{0}' already has the role '{1}',Kullanıcı '{0}' zaten bir role sahip '{1}', User Cannot Create,Kullanıcı oluşturulamıyor, User Cannot Search,Kullanıcı aranamıyor, User Defaults,Kullanıcı Varsayılanları, @@ -2662,7 +2683,7 @@ User Social Login,Kullanıcı Sosyal Girişi, User Tags,Kullanıcı Etiketleri, User Type,Kullanıcı Türü, User can login using Email id or Mobile number,Kullanıcı e-posta kimliği veya Cep telefonu numarası kullanarak giriş yapabilir, -User can login using Email id or User Name,Kullanıcı e-posta kimliği veya Kullanıcı Adı'nı kullanarak giriş yapabilir, +User can login using Email id or User Name,Kullanıcı e-posta kimliği veya Kullanıcı Adı'nı kullanarak giriş yapabilir, User editable form on Website.,Kullanıcı odaklı website formu, User is mandatory for Share,Paylaşım için bir kullanıcı zorunludur, User not allowed to delete {0}: {1},Kullanıcıya silme izni verilmedi {0}: {1}, @@ -2688,7 +2709,7 @@ Value To Be Set,Ayarlanacak Değer, Value cannot be changed for {0},Değer için değiştirilemez {0}, Value for a check field can be either 0 or 1,"Bir kontrol alanı için değer 0 ya da 1 olabilir, ya da", Value for {0} cannot be a list,{0} bir liste olamaz değer, -Value missing for,Değer eksik, +Value missing for,Bunun için değer eksik, Value too big,çok büyük bir değer, Values Changed,Değerler Değişti, Verdana,Verdana, @@ -2729,20 +2750,20 @@ Webhook Data,Webhook Veri, Webhook Header,Webhook Başlığı, Webhook Headers,Webhook Başlıklar, Webhook Request,Webhook İsteği, -Webhook URL,Web sayfası URL'si, +Webhook URL,Web sayfası URL'si, Webhooks calling API requests into web apps,Web arşivleri API isteklerini web uygulamalarına çağırıyor, -Website Meta Tag,Web Sitesi Meta Etiketi, -Website Route Meta,Web Sitesi Rotası Meta, -Website Route Redirect,Web Sitesi Rotası Yönlendirme, +Website Meta Tag,Website Meta Etiketi, +Website Route Meta,Website Rotası Meta, +Website Route Redirect,Website Rotası Yönlendirme, Website Script,Website Script, -Website Sidebar,Web sitesi Kenar Çubuğu, -Website Sidebar Item,Web sitesi Kenar Çubuğu Öğe, -Website Slideshow,Web Sitesi Slayt, -Website Slideshow Item,Web Sitesi Slayt Ürün, -Website Theme,Web Sitesi Tema, -Website Theme Image,Web Sitesi Tema Görüntü, -Website Theme Image Link,Web Sitesi Tema Görüntü Bağlantısı, -Website User,Web Sitesi Kullanım, +Website Sidebar,Website Kenar Çubuğu, +Website Sidebar Item,Website Kenar Çubuğu Öğe, +Website Slideshow,Website Slayt, +Website Slideshow Item,Website Slayt Ürün, +Website Theme,Website Teması, +Website Theme Image,Website Tema Resmi, +Website Theme Image Link,Website Tema Resmi Bağlantısı, +Website User,Website Kullanıcısı, Welcome Message,Karşılama mesajı, "When you Amend a document after Cancel and save it, it will get a new number that is a version of the old number.","Eğer sonra iptal ve bunu kaydetmek Belgeyi Amend zaman, eski sayısının bir versiyonu yeni bir numara almak olacaktır.", Width,Genişlik, @@ -2750,19 +2771,19 @@ Widths can be set in px or %.,Genişlikleri px veya% ayarlanabilir., Will be used in url (usually first name).,Url (genellikle ilk adı) kullanılır., Will be your login ID,Oturum açma kimliğiniz olacak, Will only be shown if section headings are enabled,bölüm başlıkları etkinse yalnızca gösterilir, -With Letter head,Harf Beyninle, -With Letterhead,Antetli ile, -Workflow Action,İş Akışı Eylem, -Workflow Action Master,İş Akışı Eylem Usta, -Workflow Action Name,İş Akışı Eylem Adı, +With Letter head,Antetli, +With Letterhead,Antetli, +Workflow Action,Workflow İşlemi, +Workflow Action Master,Workflow İşlem Master, +Workflow Action Name,Workflow İşlem Adı, Workflow Document State,İş Akışı Belge Durumu, -Workflow Name,İş Akışı Adı, -Workflow State,İş Akışı Durumu, -Workflow State Field,İş Akışı Durumu Tarla, -Workflow State not set,İş Akışı Durumu ayarlanmadı, -Workflow Transition,İş Akışı Geçiş, +Workflow Name,Workflow Adı, +Workflow State,Workflow Durumu, +Workflow State Field,Workflow Durumu Tarla, +Workflow State not set,Workflow Durumu ayarlanmadı, +Workflow Transition,Workflow Geçiş, Workflow state represents the current state of a document.,İş Akışı devlet belgenin mevcut durumunu gösterir., -Write,Yazmak, +Write,Yazma, Wrong fieldname {0} in add_fetch configuration of custom script,Özel komut dosyasının add_fetch yapılandırmasında yanlış alan adı {0}, X Axis Field,X Eksen Alanı, XLSX,XLSX, @@ -2777,19 +2798,19 @@ You are not allowed to print this document,Bu belgeyi yazdırma izniniz yok, You are not allowed to print this report,Bu raporu yazdırmanıza izin verilmemiştir, You are not allowed to send emails related to this document,Bu belge ile ilgili e-posta göndermenize izin verilmemiştir, You are not allowed to update this Web Form Document,Bu Web Form Belgesini güncelleme izniniz yok, -You are not connected to Internet. Retry after sometime.,İnternet'e bağlı değilsiniz. Bir ara sonra tekrar deneyin., +You are not connected to Internet. Retry after sometime.,İnternet'e bağlı değilsiniz. Bir ara sonra tekrar deneyin., You are not permitted to access this page.,Bu sayfaya erişim izniniz yok., You are not permitted to view the newsletter.,Haber bültenini görüntüleme izniniz yok., You are now following this document. You will receive daily updates via email. You can change this in User Settings.,Şimdi bu belgeyi takip ediyorsunuz. Günlük güncellemeleri e-posta ile alacaksınız. Bunu Kullanıcı Ayarlarında değiştirebilirsiniz., You can add dynamic properties from the document by using Jinja templating.,Jinja şablonları kullanarak daha iyi sonuçlar alabilirsiniz., You can also copy-paste this ,Bunu kopyalayıp yapıştırabilirsiniz., "You can change Submitted documents by cancelling them and then, amending them.","Onları değiştiren, daha sonra bunları iptal ve Ekleyen belgeleri değiştirebilirsiniz.", -You can find things by asking 'find orange in customers',Sen müşterilerin turuncu bulmak 'sorarak şeyler bulabilirsiniz, +You can find things by asking 'find orange in customers',Sen müşterilerin turuncu bulmak 'sorarak şeyler bulabilirsiniz, You can only upload upto 5000 records in one go. (may be less in some cases),En fazla 5 bin '5000' kayıt ekleyebilirsiniz bu bazı durumlarda daha azdır, You can use Customize Form to set levels on fields.,Sen alanlarda düzeylerini ayarlamak için Özelleştirmek Formunu kullanabilirsiniz., You can use wildcard %,Joker% kullanabilirsiniz, -You can't set 'Options' for field {0},{0} alanına 'Seçenekler' ayarlayamazsınız, -You can't set 'Translatable' for field {0},{0} alanına 'Çevrilemez' ayarını yapamazsınız, +You can't set 'Options' for field {0},{0} alanına 'Seçenekler' ayarlayamazsınız, +You can't set 'Translatable' for field {0},{0} alanına 'Çevrilemez' ayarını yapamazsınız, You cannot give review points to yourself,Kendinize inceleme noktaları veremezsiniz, You cannot unset 'Read Only' for field {0},{0} için 'Salt Okunur' ayarını kaldıramazsınız, You do not have enough permissions to access this resource. Please contact your manager to get access.,Bu kaynağa erişmek için yeterli izinlere sahip değilsiniz. erişmek için yöneticinizle bağlantı kurun., @@ -2952,7 +2973,7 @@ pause,Duraklat, pencil,kalem, picture,resim, plane,düzlem, -play,oynamak, +play,play, play-circle,play-circle, plus,artı, plus-sign,plus-sign, @@ -2975,7 +2996,7 @@ sa-east-1,sa-doğu-1, screenshot,ekran görüntüsü, share-alt,share-alt, shopping-cart,shopping-cart, -show,göstermek, +show,göster, signal,sinyal, star,yıldız, star-empty,star-empty, @@ -3047,7 +3068,7 @@ zoom-out,Uzaklaştırın, {0} hours ago,{0} saat önce, {0} in row {1} cannot have both URL and child items,{0} üst üste {1} URL ve alt öğeleri hem de olamaz, {0} is a mandatory field,{0} zorunlu bir alandır, -{0} is an invalid email address in 'Recipients',"{0}, 'Alıcılar' bölümünde geçersiz bir e-posta adresidir", +{0} is an invalid email address in 'Recipients',"{0}, 'Alıcılar' bölümünde geçersiz bir e-posta adresidir", {0} is not a raw printing format.,{0} ham bir baskı formatı değil., {0} is not a valid Email Address,{0} geçerli bir e-posta adresi değil, {0} is not a valid Workflow State. Please update your Workflow and try again.,{0} geçerli bir İş Akışı Durumu değil. Lütfen iş akışınızı güncelleyin ve tekrar deneyin., @@ -3091,7 +3112,7 @@ zoom-out,Uzaklaştırın, {0} {1} not found,{0} {1} bulunamadı, {0} {1} to {2},{0} {1} ile {2} arasında, "{0}, Row {1}","{0}, {1} Satır", -"{0}: '{1}' ({3}) will get truncated, as max characters allowed is {2}","{0}: izin verilen azami karakter olarak '{1}' ({3}), kesilmiş alacak {2}", +"{0}: '{1}' ({3}) will get truncated, as max characters allowed is {2}","{0}: izin verilen azami karakter olarak '{1}' ({3}), kesilmiş alacak {2}", {0}: Cannot set Amend without Cancel,{0}: Öğesi tanzim edilmeden iptal edilemez, {0}: Cannot set Assign Amend if not Submittable,{0}: Ata Amend ayarlanamaz Submittable değilse, {0}: Cannot set Assign Submit if not Submittable,{0}: Gönderilebilir değilse gönderme ataması yapılamaz, @@ -3113,7 +3134,7 @@ Last Password Reset Date,Son Şifre Sıfırlama Tarihi, The password of your account has expired.,Hesabınızın şifresinin süresi doldu., Workflow State transition not allowed from {0} to {1},İş Akışı Durumu geçişine {0} - {1} arasında izin verilmiyor, {0} must be after {1},"{0}, {1} tarihinden sonra olmalı", -{0}: Field '{1}' cannot be set as Unique as it has non-unique values,{0}: '{1}' alanı benzersiz olmayan değerlere sahip olduğundan Benzersiz olarak ayarlanamaz, +{0}: Field '{1}' cannot be set as Unique as it has non-unique values,{0}: '{1}' alanı benzersiz olmayan değerlere sahip olduğundan Benzersiz olarak ayarlanamaz, {0}: Field {1} in row {2} cannot be hidden and mandatory without default,"{0}: {2} satırındaki {1} alanı, varsayılan olmadan gizlenemez ve zorunlu değildir", {0}: Field {1} of type {2} cannot be mandatory,{0}: {2} tipindeki {1} alanı zorunlu olamaz, {0}: Fieldname {1} appears multiple times in rows {2},"{0}: {1} alan adı, {2} satırlarında birden çok kez görünüyor", @@ -3131,7 +3152,7 @@ No values to show,Gösterilecek değer yok, View Ref,Ref görüntüle, Workflow Action is not created for optional states,İsteğe bağlı durumlar için İş Akışı Eylemi oluşturulmadı, {0} values selected,{0} değer seçildi, -"""amended_from"" field must be present to do an amendment.",Bir değişiklik yapmak için "değiştirilen_" den "alan" olmalı., +"""amended_from"" field must be present to do an amendment.",Bir değişiklik yapmak için 'değiştirilen_' den 'alan' olmalı., (Mandatory),(Zorunlu), 1 Google Calendar Event synced.,1 Google Takvim Etkinliği senkronize edildi., 1 record will be exported,1 kayıt dışa aktarılacak, @@ -3191,22 +3212,22 @@ Auto Repeat failed for {0},{0} için Otomatik Tekrarlama başarısız oldu, Automatic Linking can be activated only for one Email Account.,Otomatik Bağlama yalnızca bir E-posta Hesabı için etkinleştirilebilir., Automatic Linking can be activated only if Incoming is enabled.,Otomatik Bağlama yalnızca Gelen seçeneği etkinse etkinleştirilebilir., Automatically generates recurring documents.,Otomatik olarak yinelenen belgeler oluşturur., -Backing up to Google Drive.,Google Drive'a yedekleme., +Backing up to Google Drive.,Google Drive'a yedekleme., Backup Folder ID,Yedekleme Klasörü Kimliği, Backup Folder Name,Yedekleme Klasörü Adı, -Before Cancel,İptal Etmeden Önce, +Before Cancel,İptalden Önce, Before Delete,Silmeden Önce, -Before Insert,Yerleştirmeden Önce, +Before Insert,Eklemeden Önce, Before Save,Kaydetmeden Önce, Before Save (Submitted Document),Kaydetmeden Önce (Gönderilen Belge), Before Submit,Göndermeden Önce, -Blank Template,Boş şablon, -Callback URL,Geri arama URL'si, +Blank Template,Boş Şablon, +Callback URL,Geri arama URL'si, Cancel All Documents,Tüm Belgeleri İptal Et, Cancelling documents,Belgeleri iptal etme, Cannot match column {0} with any field,{0} sütunu herhangi bir alanla eşleştirilemiyor, -Change,Değişiklik, -Change User,Kullanıcı Değiştir, +Change,Değiştir, +Change User,Kullanıcıyı Değiştir, Check the Error Log for more information: {0},Daha fazla bilgi için Hata Günlüğünü kontrol edin: {0}, Clear Cache and Reload,Önbelleği ve Yeniden Yüklemeyi Temizle, Clear Filters,Filtreleri Temizle, @@ -3214,7 +3235,7 @@ Click on Authorize Google Drive Access to authorize Google Drive Access., Click on a file to select it.,Seçmek için bir dosyaya tıklayın., Click on the link below to approve the request,İsteği onaylamak için aşağıdaki linke tıklayın, Click on the lock icon to toggle public/private,Herkese açık / özel arasında geçiş yapmak için kilit simgesine tıklayın, -Click on {0} to generate Refresh Token.,Yenileme Tokenini oluşturmak için {0} 'a tıklayın., +Click on {0} to generate Refresh Token.,Yenileme Tokenini oluşturmak için {0} 'a tıklayın., Close Condition,Yakın Durumu, Column {0},{0} sütunu, Columns / Fields,Sütunlar / Alanlar, @@ -3228,24 +3249,24 @@ Contribute Translations,Çevirilere Katkıda Bulunun, Contributed,Katkıda, Controller method get_razorpay_order missing,Denetleyici yöntemi get_razorpay_order eksik, Copied to clipboard.,Panoya kopyalandı., -Core Modules {0} cannot be searched in Global Search.,Global Arama'da {0} Çekirdek Modülleri aranamıyor., +Core Modules {0} cannot be searched in Global Search.,Global Arama'da {0} Çekirdek Modülleri aranamıyor., Could not create Razorpay order. Please contact Administrator,Razorpay siparişi oluşturulamadı. Lütfen Yönetici ile iletişime geçin, Could not create razorpay order,Razorpay siparişi oluşturulamadı, -Create Log,Günlük Oluştur, -Create your first {0},İlk {0} ürününüzü oluşturun, +Create Log,Log Oluştur, +Create your first {0},İlk {0} kaydını oluşturun, Created {0} records successfully.,{0} kayıt başarıyla oluşturuldu., -Cron,cron, +Cron,Cron, Cron Format,Cron Biçimi, -Daily Events should finish on the Same Day.,Günlük Olaylar Aynı Gün'de Bitmelidir., +Daily Events should finish on the Same Day.,Günlük Olaylar Aynı Gün'de Bitmelidir., Daily Long,Günlük Uzun, -Default Role on Creation,Yaratılışta Varsayılan Rol, -Default Theme,Varsayılan tema, +Default Role on Creation,Oluşturmada Varsayılan Rol, +Default Theme,Varsayılan Tema, Default {0},Varsayılan {0}, -Delete All,Hepsini sil, +Delete All,Tümünü Sil, Do you want to cancel all linked documents?,Bağlantılı tüm belgeleri iptal etmek istiyor musunuz?, -DocType Action,DocType İşlemi, -DocType Event,DocType Etkinliği, -DocType Link,DocType Bağlantısı, +DocType Action,Belge Türü İşlemi, +DocType Event,Belge Türü Etkinliği, +DocType Link,Belge Türü Bağlantısı, Document Share,Belge Paylaşımı, Document Tag,Belge Etiketi, Document Title,Belge başlığı, @@ -3258,9 +3279,9 @@ Documentation Link,Doküman Bağlantısı, Don't Import,Alma, Don't Send Emails,E-posta Gönderme, "Drag and drop files, ","Dosyaları sürükleyip bırakın,", -Drop,Düşürmek, +Drop,Bırak, Drop Here,Buraya bırak, -Drop files here,dosyaları buraya bırak, +Drop files here,Dosyaları buraya bırak, Dynamic Template,Dinamik Şablon, ERPNext Role,ERPNext Rolü, Email / Notifications,E-posta Bildirimleri, @@ -3268,10 +3289,10 @@ Email Account setup please enter your password for: {0},E-posta Hesabı kurulumu Email Address whose Google Contacts are to be synced.,Google Kişileri senkronize edilecek e-posta adresi., "Email ID must be unique, Email Account already exists for {0}","E-posta kimliği benzersiz olmalıdır, {0} için E-posta Hesabı zaten var", Email IDs,E-posta Noları, -Enable Allow Auto Repeat for the doctype {0} in Customize Form,Özelleştir Formunda {0} doctype için Otomatik Tekrarlamaya İzin Ver'i etkinleştirin, +Enable Allow Auto Repeat for the doctype {0} in Customize Form,Formu Özelleştir'de {0} doküman türü için Otomatik Tekrara İzin Ver'i etkinleştirin, Enable Automatic Linking in Documents,Belgelerde Otomatik Bağlamayı Etkinleştirme, Enable Email Notifications,E-posta Bildirimlerini Etkinleştir, -Enable Google API in Google Settings.,Google Ayarlarında Google API'yi etkinleştirin., +Enable Google API in Google Settings.,Google Ayarlarında Google API'yi etkinleştirin., Enable Security,Güvenliği Etkinleştir, Energy Point,Enerji Noktası, Enter Client Id and Client Secret in Google Settings.,Google Ayarları’na Müşteri Kimliği ve Müşteri Sırrı girin., @@ -3295,13 +3316,13 @@ Export Type,İhracat Şekli, Export {0} records,{0} kayıtlarını dışa aktar, Failed to connect to the Event Producer site. Retry after some time.,Etkinlik Üreticisi sitesine bağlanılamadı. Bir süre sonra tekrar deneyin., Failed to create an Event Consumer or an Event Consumer for the current site is already registered.,Mevcut site için Etkinlik Tüketicisi veya Etkinlik Tüketicisi oluşturulamadı zaten kayıtlı., -Failure,başarısızlık, +Failure,Başarısız, Fetching default Global Search documents.,Varsayılan Global Arama belgelerini alma., Fetching posts...,Yayınlar alınıyor ..., Field Mapping,Alan Eşleme, Field To Check,Kontrol Edilecek Alan, File Information,Dosya bilgisi, -Filter By,Tarafından filtre, +Filter By,Filtrele, Filtered Records,Filtrelenmiş Kayıtlar, Filters applied for {0},{0} için uygulanan filtreler, Finished,bitirdi, @@ -3312,13 +3333,13 @@ For Document Event,Belge Etkinliği İçin, "For performance, only the first 100 rows were processed.","Performans için, yalnızca ilk 100 satır işlendi.", Form URL-Encoded,Form URL Kodlu, Frequently Visited Links,Sık Ziyaret Edilen Bağlantılar, -From Date,Tarihinden itibaren, +From Date,Başlangıç Tarihi, From User,Kullanıcıdan, Global Search DocType,Genel Arama Doküman Türü, Global Search Document Types Reset.,Genel Arama Doküman Tipleri Sıfırlama., Global Search Settings,Global Arama Ayarları, Global Shortcuts,Global Kısayollar, -Go,Gitmek, +Go,Git, Go to next record,Bir sonraki kayda git, Go to previous record,Önceki kayda git, Google API Settings.,Google API Ayarları., @@ -3334,14 +3355,14 @@ Google Calendar Integration.,Google Takvim Entegrasyonu., Google Calendar has been configured.,Google Takvim yapılandırıldı., Google Contacts,Google Kişileri, "Google Contacts - Could not sync contacts from Google Contacts {0}, error code {1}.","Google Kişileri - {0} Google Rehberindeki kişiler senkronize edilemedi, hata kodu {1}.", -"Google Contacts - Could not update contact in Google Contacts {0}, error code {1}.","Google Kişileri - {0} Google Kişileri'nde kişi güncellenemedi, hata kodu {1}.", +"Google Contacts - Could not update contact in Google Contacts {0}, error code {1}.","Google Kişileri - {0} Google Kişileri'nde kişi güncellenemedi, hata kodu {1}.", Google Contacts Id,Google Kişiler Kimliği, Google Contacts Integration is disabled.,Google Rehber Entegrasyonu devre dışı., Google Contacts Integration.,Google Rehber Entegrasyonu., Google Contacts has been configured.,Google Kişileri yapılandırıldı., Google Drive,Google Drive, -Google Drive - Could not create folder in Google Drive - Error Code {0},Google Drive - Google Drive'da klasör oluşturulamadı - Hata Kodu {0}, -Google Drive - Could not find folder in Google Drive - Error Code {0},Google Drive - Google Drive'da klasör bulunamadı - Hata Kodu {0}, +Google Drive - Could not create folder in Google Drive - Error Code {0},Google Drive - Google Drive'da klasör oluşturulamadı - Hata Kodu {0}, +Google Drive - Could not find folder in Google Drive - Error Code {0},Google Drive - Google Drive'da klasör bulunamadı - Hata Kodu {0}, Google Drive Backup Successful.,Google Drive Yedekleme Başarılı., Google Drive Backup.,Google Drive Yedekleme., Google Drive Integration.,Google Drive Entegrasyonu., @@ -3359,7 +3380,7 @@ HTML Page,HTML Sayfası, Has Mapping,Haritalama Var, Hourly Long,Saatlik Uzun, "If non-standard port (e.g. POP3: 995/110, IMAP: 993/143)","Standart olmayan bağlantı noktası ise (örneğin, POP3: 995/110, IMAP: 993/143)", -If the document has different field names on the Producer and Consumer's end check this and set up the Mapping,Belgenin Üretici ve Tüketici tarafında farklı alan adları varsa bunu kontrol edin ve Eşleme'yi ayarlayın, +If the document has different field names on the Producer and Consumer's end check this and set up the Mapping,Belgenin Üretici ve Tüketici tarafında farklı alan adları varsa bunu kontrol edin ve Eşleme'yi ayarlayın, If this is checked the documents will have the same name as they have on the Event Producer's site,"Bu işaretlenirse, belgeler Etkinlik Üreticisinin sitesiyle aynı ada sahip olur", Illegal SQL Query,Geçersiz SQL Sorgusu, Import File,Önemli dosya, @@ -3377,18 +3398,29 @@ Incoming Change,Gelen Değişiklik, Invalid Filter Value,Geçersiz Filtre Değeri, Invalid URL,Geçersiz URL, Invalid field name: {0},Geçersiz alan adı: {0}, -Invalid file URL. Please contact System Administrator.,Geçersiz dosya URL'si. Lütfen Sistem Yöneticisi ile irtibata geçin., +Invalid file URL. Please contact System Administrator.,Geçersiz dosya URL'si. Lütfen Sistem Yöneticisi ile irtibata geçin., Invalid include path,Geçersiz yol ekle, Invalid username or password,Geçersiz kullanıcı adı veya şifre, Is Primary,Birincil mi, -Is Primary Mobile,Birincil Mobil mi, +Is Primary Mobile,Birincil Cep mi, Is Primary Phone,Birincil Telefon mu, Is Tree,Ağaç mı, JSON Request Body,JSON İstek Organı, Javascript is disabled on your browser,Tarayıcınızda Javascript devre dışı, Job,İş, Jump to field,Alana atla, -Keyboard Shortcuts,Klavye kısayolları, +Documentation,Dokümantasyon, +User Forum,Kullanıcı Forumu, +Report an Issue,Sorun Bildir, +About,Hakkında +Frappe Support,Frappe Desteği, +Keyboard Shortcuts,Klavye Kısayolları, +My Profile,Profilim, +My Settings,Ayarlarım, +Manage Subscriptions,Abonelikleri Yönet, +Toggle Full Width,Tam Genişliği Değiştir, +Toggle Theme,Temayı Değiştir, +Log out,Çıkış Yap, LDAP Group,LDAP Grubu, LDAP Group Field,LDAP Grup Alanı, LDAP Group Mapping,LDAP Grup Eşlemesi, @@ -3404,12 +3436,14 @@ Last Backup On,Son Yedekleme Açık, Last Execution,Son Yürütme, Last Sync On,Son Senkronizasyon Açık, Last Update,Son Güncelleme, -Last refreshed,Son yenilendi, -Link Document Type,Bağlantı Doküman Türü, -Link Fieldname,Bağlantı Alan Adı, +Last refreshed,Son Yenilenme, +Link Document Type,Belge Türü Bağlantısı, +Link Fieldname,Alan Adı Bağlantısı, Loading import file...,İçe aktarılan dosya yükleniyor ..., Local Document Type,Yerel Belge Türü, -Log Data,Günlük Verileri, +Log Data,Log Verisi, +There are no upcoming events for you.,Sizin için yaklaşan etkinlik yok., +Looks like you haven’t received any notifications.,Herhangi bir bildirim almadığınız anlaşılıyor., Main Section (HTML),Ana Bölüm (HTML), Main Section (Markdown),Ana Bölüm (Markdown), "Maintains a Log of all inserts, updates and deletions on Event Producer site for documents that have consumers.","Tüketici içeren belgeler için Event Producer sitesindeki tüm eklerin, güncellemelerin ve silme işlemlerinin bir kaydını tutar.", @@ -3448,24 +3482,26 @@ No filters found,Filtre bulunamadı, No more items to display,Görüntülenecek başka öğe yok, No more posts,Başka mesaj yok, No new Google Contacts synced.,Yeni Google Kişisi senkronize edilmedi., +No New notifications,Yeni bildirim yok, No pending or current jobs for this site,Bu site için beklemede veya mevcut iş yok, No posts yet,Henüz yayın yok, No records will be exported,Hiçbir kayıt dışa aktarılmayacak, -No results found for {0} in Global Search,Global Arama'da {0} için sonuç bulunamadı, +No results found for {0} in Global Search,Global Arama'da {0} için sonuç bulunamadı, No user found,Kullanıcı bulunamadı, +No function matches the given name and argument types. You might need to add explicit type casts.,Verilen ad ve bağımsız değişken türleriyle eşleşen işlev yok. Açık tür atamaları eklemeniz gerekebilir., Not Specified,Belirtilmemiş, Notification Log,Bildirim Günlüğü, -Notification Settings,Bildirim ayarları, +Notification Settings,Bildirim Ayarları, Notification Subscribed Document,Bildirim Abone Belgesi, Notifications Disabled,Bildirimler Devre Dışı, Number of Groups,Grup Sayısı, OAuth Client ID,OAuth Müşteri Kimliği, OTP setup using OTP App was not completed. Please contact Administrator.,OTP App kullanarak OTP kurulumu tamamlanmadı. Lütfen Yönetici ile iletişime geçin., Only one {0} can be set as primary.,Yalnızca bir {0} birincil olarak ayarlanabilir., -Open Awesomebar,Awesomebar'ı açın, +Open Awesomebar,Awesomebar'ı açın, Open Chat,Açık sohbet, Open Documents,Açık Belgeler, -Open Help,Yardım'ı aç, +Open Help,Yardım'ı aç, Open Settings,Ayarları aç, Open list item,Liste öğesini aç, Organizational Unit for Users,Kullanıcılar için Organizasyon Birimi, @@ -3480,16 +3516,16 @@ Please find attached {0}: {1},Lütfen ekte {0} bul: {1}, Please select applicable Doctypes,Lütfen geçerli Dokümanları seçin, Portrait,Portre, Press Alt Key to trigger additional shortcuts in Menu and Sidebar,Menü ve Kenar Çubuğundaki ek kısayolları tetiklemek için Alt Tuşuna basın., -Print Settings...,Yazdırma Ayarları ..., +Print Settings...,Baskı Ayarları ..., Producer Document Name,Üretici Doküman Adı, -Producer URL,Üretici URL'si, +Producer URL,Üretici URLsi, Property Depends On,Mülkiyet Bağlıdır, Pull from Google Calendar,Google Takvim’den çekin, -Pull from Google Contacts,Google Kişiler'den çekin, +Pull from Google Contacts,Google Kişiler'den çekin, Pulled from Google Calendar,Google Takvim’den Çekti, Pulled from Google Contacts,Google Kişilerden Alındı, Push to Google Calendar,Google Takvim’e git, -Push to Google Contacts,Google Kişileri'ne git, +Push to Google Contacts,Google Kişilerine git, Queue / Worker,Sıra / İşçi, RAW Information Log,RAW Bilgi Günlüğü, Raw Printing Settings...,Ham Baskı Ayarları ..., @@ -3502,7 +3538,7 @@ Remote Document Type,Uzak Belge Türü, Repeat on Last Day of the Month,Ayın Son Gününde Tekrarla, Repeats {0},{0} tekrarları, Report Information,Rapor Bilgisi, -Report with more than 10 columns looks better in Landscape mode.,"Manzara modunda, 10'dan fazla sütunlu rapor daha iyi görünür.", +Report with more than 10 columns looks better in Landscape mode.,"Manzara modunda, 10'dan fazla sütunlu rapor daha iyi görünür.", Request Structure,İstek Yapısı, Restricted,Kısıtlı, Restrictions,Kısıtlamalar, @@ -3526,10 +3562,10 @@ Select Date Range,Tarih Aralığı Seçin, Select Field,Alan Seç, Select Field...,Alan Seçiniz ..., Select Filters,Filtreleri Seç, -Select Google Calendar to which event should be synced.,Etkinliğin senkronize edilmesi gereken Google Takvim'i seçin., -Select Google Contacts to which contact should be synced.,Kişinin senkronize edileceği Google Rehber'i seçin., +Select Google Calendar to which event should be synced.,Etkinliğin senkronize edilmesi gereken Google Takvim'i seçin., +Select Google Contacts to which contact should be synced.,Kişinin senkronize edileceği Google Rehber'i seçin., Select Group By...,Grupla Seç ..., -Select Mandatory,zorunlu seçin, +Select Mandatory,Zorunlu Seç, Select atleast 2 actions,En az 2 eylem seç, Select list item,Liste öğesini seçin, Select multiple list items,Birden fazla liste öğesi seç, @@ -3549,15 +3585,15 @@ Show More Activity,Daha Fazla Etkinlik Göster, Show Traceback,Geri İzlemeyi Göster, Show Warnings,Uyarıları Göster, Showing only first {0} rows out of {1},{1} içinden yalnızca ilk {0} satır gösteriliyor, -"Simple Python Expression, Example: Status in (""Invalid"")","Basit Python İfadesi, Örnek: Durum ("Geçersiz")", +"Simple Python Expression, Example: Status in (""Invalid"")","Basit Python İfadesi, Örnek: Durum ('Geçersiz')", Skipping Untitled Column,Adsız Sütunu Atla, Skipping column {0},{0} sütunu atlanıyor, Social Home,Sosyal Ev, -Some columns might get cut off when printing to PDF. Try to keep number of columns under 10.,PDF'ye yazdırırken bazı sütunlar kesilebilir. Sütun sayısını 10'un altında tutmaya çalışın., -Something went wrong during the token generation. Click on {0} to generate a new one.,Jeton üretimi sırasında bir şeyler ters gitti. Yeni bir tane oluşturmak için {0} 'a tıklayın., +Some columns might get cut off when printing to PDF. Try to keep number of columns under 10.,PDF'ye yazdırırken bazı sütunlar kesilebilir. Sütun sayısını 10'un altında tutmaya çalışın., +Something went wrong during the token generation. Click on {0} to generate a new one.,Jeton üretimi sırasında bir şeyler ters gitti. Yeni bir tane oluşturmak için {0} 'a tıklayın., Submit After Import,İçe Aktardıktan Sonra Gönder, Submitting...,Gönderiliyor ..., -Success! You are good to go 👍,Başarı! Gitmek için iyi birisin 👍, +Success! You are good to go 👍,Başarı! İlerleme için iyi durumdasın 👍, Successful Transactions,Başarılı İşlemler, Successfully Submitted!,Başarıyla gönderildi!, Successfully imported {0} record.,{0} kaydı başarıyla içe aktarıldı., @@ -3569,7 +3605,7 @@ Sync Contacts,Kişileri Eşitle, Sync with Google Calendar,Google Takvim ile senkronize et, Sync with Google Contacts,Google Kişileri ile senkronize et, Synced,Senkronize edildi, -Syncing,Senkronizasyon, +Syncing,Senkronize ediliyor, Syncing {0} of {1},{1} üzerinden {0} senkronizasyonu, Tag Link,Etiket Bağlantısı, Take Backup,Yedekleme al, @@ -3591,20 +3627,20 @@ This cannot be undone,Bu geri alınamaz, Time Format,Zaman formatı, Time series based on is required to create a dashboard chart,Bir gösterge tablosu grafiği oluşturmak için zamana dayalı zaman serileri gerekir, Time {0} must be in format: {1},{0} süresi şu biçimde olmalıdır: {1}, -"To configure Auto Repeat, enable ""Allow Auto Repeat"" from {0}.",Otomatik Tekrarı yapılandırmak için {0} 'dan "Otomatik Tekrarlamaya İzin Ver" i etkinleştirin., +"To configure Auto Repeat, enable ""Allow Auto Repeat"" from {0}.",Otomatik Tekrarı yapılandırmak için {0} 'dan 'Otomatik Tekrarlamaya İzin Ver' i etkinleştirin., To enable it follow the instructions in the following link: {0},Etkinleştirmek için aşağıdaki bağlantıdaki talimatları izleyin: {0}, "To use Google Calendar, enable {0}.",Google Takvim’i kullanmak için {0} seçeneğini etkinleştirin., -"To use Google Contacts, enable {0}.",Google Kişileri'ni kullanmak için {0} seçeneğini etkinleştirin., -"To use Google Drive, enable {0}.",Google Drive'ı kullanmak için {0} seçeneğini etkinleştirin., +"To use Google Contacts, enable {0}.",Google Kişileri'ni kullanmak için {0} seçeneğini etkinleştirin., +"To use Google Drive, enable {0}.",Google Drive'ı kullanmak için {0} seçeneğini etkinleştirin., Today's Events,Bugünkü Etkinlikler, -Toggle Public/Private,Genel / Özel'e Geçiş Yap, +Toggle Public/Private,Genel / Özel'e Geçiş Yap, Tracks milestones on the lifecycle of a document if it undergoes multiple stages.,"Birden çok aşamadan geçerse, bir belgenin kullanım ömrünün kilometre taşlarını izler.", Tree structures are implemented using Nested Set,"Ağaç yapıları, Yuvalanmış Küme kullanılarak uygulanır", Trigger Primary Action,Birincil İşlemi Tetikle, URL for documentation or help,Dokümantasyon veya yardım için URL, -URL must start with 'http://' or 'https://',"URL, 'http: //' veya 'https: //' ile başlamalıdır.", -Unchanged,değişmemiş, -Unpin,sabitlemesini, +URL must start with 'http://' or 'https://',"URL, 'http: //' veya 'https: //' ile başlamalıdır.", +Unchanged,Değiştirilmedi, +Unpin,Sabitlemeyi kaldır, Untitled Column,Başlıksız Sütun, Untranslated,çevrilmemiş, Upcoming Events,Yaklaşan Etkinlikler, @@ -3612,21 +3648,21 @@ Update Existing Records,Mevcut Kayıtları Güncelle, Update Type,Güncelleme Türü, Updated To A New Version 🎉,Yeni Bir Sürüme Güncellenmiş 🎉, "Updating {0} of {1}, {2}","{1}, {2} ürününün {0} güncellenmesi", -Upload file,Dosya yükleme, -Upload {0} files,{0} dosya yükle, -Uploaded To Google Drive,Google Drive'a Yüklendi, +Upload file,Dosya yükle, +Upload {0} files,{0} dosyaları yükle, +Uploaded To Google Drive,Google Drive'a Yüklendi, Uploaded successfully,Başarıyla yüklendi, Uploading {0} of {1},{1} üzerinden {0} yüklüyor, Use SSL for Outgoing,Giden için SSL kullan, Use Same Name,Aynı Adı Kullan, Used For Google Maps Integration.,Google Haritalar Entegrasyonu İçin Kullanıldı., User ID Property,Kullanıcı Kimliği Özelliği, -User Profile,Kullanıcı profili, -User Settings,Kullanıcı ayarları, +User Profile,Kullanıcı Profili, +User Settings,Kullanıcı Ayarları, User does not exist,Kullanıcı yok, User {0} has requested for data deletion,{0} kullanıcısı veri silme talebinde bulundu, Users assigned to the reference document will get points.,Referans dokümana atanan kullanıcılar puan kazanacaktır., -Value must be one of {0},"Değer, {0} 'dan biri olmalı", +Value must be one of {0},"Değer, {0} 'dan biri olmalı", Value {0} missing for {1},{1} için {0} değeri eksik, Verification,Doğrulama, Verification Code,Doğrulama kodu, @@ -3647,7 +3683,7 @@ You are not allowed to export {} doctype,{} Doktipini dışa aktarmanıza izin v You can try changing the filters of your report.,Raporunuzun filtrelerini değiştirmeyi deneyebilirsiniz., You do not have permissions to cancel all linked documents.,Bağlı tüm belgeleri iptal etme izniniz yok., You need to create these first: ,Önce bunları oluşturmanız gerekir:, -You need to enable JavaScript for your app to work.,Uygulamanızın çalışması için JavaScript'i etkinleştirmeniz gerekir., +You need to enable JavaScript for your app to work.,Uygulamanızın çalışması için JavaScript'i etkinleştirmeniz gerekir., You need to install pycups to use this feature!,Bu özelliği kullanmak için pycups yüklemeniz gerekiyor!, Your Target,Senin hedefin, "browse,","Araştır,", @@ -3678,8 +3714,8 @@ via Data Import,Veri Alma yoluyla, {0} should not be same as {1},"{0}, {1} ile aynı olmamalıdır", {0} translations pending,{0} çeviri bekleniyor, {0} {1} is linked with the following submitted documents: {2},{0} {1} şu gönderilen belgelerle bağlantılıdır: {2}, -"{0}: Failed to attach new recurring document. To enable attaching document in the auto repeat notification email, enable {1} in Print Settings",{0}: Yinelenen yeni belge eklenemedi. Otomatik tekrar bildirim e-postasında belge eklemeyi etkinleştirmek için Yazdırma Ayarları'nda {1} seçeneğini etkinleştirin, -{0}: Fieldname cannot be one of {1},{0}: Alan adı {1} 'den biri olamaz, +"{0}: Failed to attach new recurring document. To enable attaching document in the auto repeat notification email, enable {1} in Print Settings",{0}: Yinelenen yeni belge eklenemedi. Otomatik tekrar bildirim e-postasında belge eklemeyi etkinleştirmek için Yazdırma Ayarları'nda {1} seçeneğini etkinleştirin, +{0}: Fieldname cannot be one of {1},{0}: Alan adı {1} 'den biri olamaz, {} Complete,{} Tamamlayınız, ← Back to upload files,← Dosya yüklemeye geri dön, Activity,Aktivite, @@ -3698,14 +3734,14 @@ Chart,Grafik, Close,Kapat, Communication,İletişim, Compact Item Print,Kompakt Öğe Yazdır, -Company,şirket, -Complete,Komple, +Company,Şirket, +Complete,Tamamla, Completed,Tamamlandı, -Continue,Devam etmek, -Country,ülke, -Creating {0},{0} oluşturma, -Currency,Para birimi, -Customize,Özelleştirme, +Continue,Devam et, +Country,Ülke, +Creating {0},{0} oluşturuluyor, +Currency,Para Birimi, +Customize,Özelleştir, Daily,Günlük, Date,Tarih, Dear,Sevgili, @@ -3714,19 +3750,20 @@ Delete,Sil, Description,Açıklama, Designation,Atama, Disabled,Devredışı, -Doctype,DOCTYPE, +Doctype,Belge Türü, Download Template,Şablonu İndir, -Dr,Dr, -Due Date,Bitiş tarihi, +Dr,Borç, +Due Date,Bitiş Tarihi, Duplicate,Kopyala, +Edit Full Form,Tam Formu Düzenle, Edit Profile,Profili Düzenle, -Email,EPosta, +Email,E-posta, End Time,Bitiş Zamanı, Enter Value,Değeri girin, Entity Type,Varlık Türü, Error,Hata, Expired,Süresi Doldu, -Export,Dışarı aktar, +Export,Dışarı Aktar, Export not allowed. You need {0} role to export.,İhracat izin verilmiyor. Vermek {0} rol gerekir., Fetching...,Getiriliyor ..., Field,Alan, @@ -3734,19 +3771,19 @@ File Manager,Dosya Yöneticisi, Filters,Filtreler, Get Items,Ürünleri alın, Goal,Hedef, -Group,grup, +Group,Grup, Group Node,Grup Düğüm, Help,Yardım, -Help Article,Yardım Madde, +Help Article,Yardım Maddesi, Home,Ana Sayfa, Import Data from CSV / Excel files.,CSV / Excel dosyalarından Veri Aktar., -In Progress,Devam etmekte, +In Progress,Devam ediyor, Intermediate,Orta düzey, -Invite as User,Kullanıcı olarak davet, +Invite as User,Kullanıcı olarak davet et, "It seems that there is an issue with the server's stripe configuration. In case of failure, the amount will get refunded to your account.","Sunucunun şerit yapılandırmasında bir sorun var gibi görünüyor. Arıza durumunda, tutar hesabınıza iade edilir.", Loading...,Yükleniyor..., Location,Konum, -Looks like someone sent you to an incomplete URL. Please ask them to look into it.,Birisi eksik URL'ye gönderdi benziyor. içine bakmak için isteyin., +Looks like someone sent you to an incomplete URL. Please ask them to look into it.,Birisi eksik URL'ye gönderdi benziyor. içine bakmak için isteyin., Master,Ana Kaynak, Message,İleti, Missing Values Required,Gerekli Eksik Değerler, @@ -3756,16 +3793,16 @@ Name,İsim, Newsletter,Bülten, Not Allowed,İzin Değil, Note,Not, -Offline,Çevrimdışı, +Offline,Offline, Open,Açık, Page {0} of {1},{1} Sayfadan {0}., Pay,Ödeme, Pending,Bekliyor, Phone,Telefon, Please click on the following link to set your new password,Yeni şifrenizi ayarlamak için aşağıdaki linke tıklayınız, -Please select another payment method. Stripe does not support transactions in currency '{0}',"Lütfen başka bir ödeme yöntemi seçin. Şerit, '{0}' para birimi işlemlerini desteklemez", +Please select another payment method. Stripe does not support transactions in currency '{0}',"Lütfen başka bir ödeme yöntemi seçin. Şerit, '{0}' para birimi işlemlerini desteklemez", Please specify,Lütfen belirtiniz, -Printing,Baskı, +Printing,Yazdırma, Priority,Öncelik, Project,Proje, Quarterly,Üç ayda bir, @@ -3773,24 +3810,25 @@ Queued,Sıraya alınmış, Quick Entry,Hızlı Girişi, Reason,Nedeni, Refreshing,Güncelleniyor, -Rename,Yeniden adlandır, +Rename,Yeniden Adlandır, +Copy to Clipboard,Panoya Kopyala, Reset,Sıfırla, -Review,gözden geçirmek, -Room,oda, -Room Type,Oda tipi, +Review,Gözden geçir, +Room,Oda, +Room Type,Oda Tipi, Save,Kaydet, Search results for,için arama sonuçları, Select All,Tümünü Seç, Send,Gönder, -Sending,Gönderme, -Server Error,Server hatası, +Sending,Gönderiliyor, +Server Error,Sunucu Hatası, Set,Ayarla, Setup,Kurulum, Setup Wizard,Kurulum Sihirbazı, Size,Boyut, -Sr,Sr, +Sr,Kıdemli, Start,Başlangıç, -Start Time,Başlangıç Zamanı, +Start Time,Başlama Zamanı, Status,Durum, Submitted,Tanzim Edildi, Tag,Etiket, @@ -3801,7 +3839,7 @@ Total,Toplam, Totals,Toplamlar, Tuesday,Salı, Type,Türü, -Update,Güncelleme, +Update,Güncelle, User {0} is disabled,Kullanıcı {0} devre dışı, Users and Permissions,Kullanıcılar ve İzinler, Warehouse,Depo, @@ -3811,14 +3849,14 @@ Yearly,Yıllık, You,Siz, You can also copy-paste this link in your browser,Ayrıca bu linki kopyalayıp tarayıcınıza yapıştırabilirsiniz, and,ve, -{0} Name,{0} Ad, +{0} Name,{0} Adı, {0} is required,{0} gereklidir, ALL,Tümü, Attach File,Dosya Eki, Barcode,Barkod, Beginning with,İle başlayan, -Bold,cesur, -CANCELLED,İptal Edilmiş, +Bold,Kalın, +CANCELLED,İptal Edildi, Calendar,Takvim, Center,Merkez, Clear,Açık, @@ -3826,89 +3864,90 @@ Comment,Yorum Yap, Comments,Yorumlar, DRAFT,Taslak, Dashboard,Gösterge Paneli, -DocType,DocType, +DocType,BelgeTipi, Download,İndir, EMail,E-posta, Edit in Full Page,Tam Sayfada Düzenle, Email Inbox,E-posta Gelen Kutusu, File,Dosya, -Forward,ileri, +Forward,İlet, Icon,ikon, In,IN, Inbox,Gelen kutusu, Insert New Records,Yeni Kayıt Ekle, JavaScript,Javascript, LDAP Settings,LDAP Ayarları, -Left,Bırakmak, -Like,Sevmek, +Left,Bırak, +Like,Benzer, Link,bağlantı, -Logged in,Giriş, +Logged in,Giriş yapıldı, New,Yeni, Not Found,Bulunamadı, -Not Like,Gibi değil, +Not Like,Benzer değil, Notify by Email,E-postayla Bildir, -Now,şimdi, -Off,kapalı, +Now,Şimdi, +Off,Kapalı, One of,Biri, Page,Sayfa, Print,Yazdır, Reference Name,referans adı, Refresh,Yenile, -Repeat,tekrarlamak, +Repeat,Tekrarla, Right,Sağ, Roles HTML,Roller HTML, Scheduled To Send,göndermek için planlanmış, Search Results for ,İçin arama sonuçları, Send Notification To,Için Bildirim gönder, -Success,başarı, +Success,Başarılı, Tags,Etiketler, -Time,zaman, +Time,Zaman, Updated Successfully,başarıyla güncellendi, Upload,Karşıya Yükle, -User ,kullanıcı, -Value,değer, +User,Kullanıcı, +Value,Değer, +Settings,Ayarlar, Web Link,Web bağlantısı, Your Email Address,E-posta adresiniz, Desktop,Masaüstü, Usage Info,kullanım Bilgisi, Download Backups,Yedeklemeleri İndir, Recorder,Ses kayıt cihazı, -Role Permissions Manager,Rol İzinler Müdürü, +Role Permissions Manager,Rol İzin Yöneticisi, Translation Tool,Çeviri Aracı, Awaiting password,Şifre bekleniyor, Current status,Şu anki durum, Download template,Şablonu indir, -Edit in full page,tam sayfa düzenle, +Edit in full page,Tüm sayfada düzenle, Email Id,E-posta kimliği, Email address,E-posta Adresi, Ends on,Biteceği tarih, Half-yearly,Yarı yıllık, Hidden,Gizli, Javascript,JavaScript, -Ldap settings,Ldap ayarları, -Mobile number,Cep numarası, +Ldap settings,LDAP Ayarları, +Mobile number,Cep Numarası, Mx,mx, No,Hiç, Not found,Bulunamadı, Notes:,Notlar:, Notify by email,E-postayla bildir, -Permitted Documents For User,Kullanıcı için izin Belgeler, +Permitted Documents For User,Kullanıcı için İzin verilen Belgeler, Reference Docname,Referans DokümanAdı, -Reference Doctype,Referans DocType, +Reference Doctype,Referans Belge Türü, Reference name,Referans Adı, Roles Html,Roller Html, -Row #,Kürek çekmek #, -Scheduled to send,Gönderme Zamanı, -Select Doctype,DocType'ı seçin, +Row #,Satır No #, +Scheduled to send,Göndermek için planlandı, +Select Doctype,Belge türünü seçin, Send Email for Successful backup,Başarılı Yedekleme için E-posta Gönder, -Sign up,kaydol, +Sign up,Kaydol, Time format,Zaman formatı, Upload failed,Yükleme başarısız, -User Id,Kullanıcı kimliği, +User Id,Kullanıcı ID, Yes,Evet, -Your email address,e, -added,katma, -added {0},Eklenen {0}, +Your email address,Eposta adresiniz, +added,eklendi, +added {0},{0} eklendi, barcode,barkod, beginning with,ile başlayan, blue,mavi, @@ -3916,39 +3955,39 @@ bold,kalın, book,kitap, calendar,takvim, certificate,sertifika, -check,Kontrol, -clear,açık, -comment,Yorum yap, +check,kontrol et, +clear,temizle, +comment,yorum yap, comments,yorumlar, -created,Oluşturuldu, -danger,Tehlike, +created,oluşturuldu, +danger,tehlike, dashboard,gösterge paneli, download,indir, -edit,Düzenle, -email inbox,E-posta gelen kutusu, +edit,düzenle, +email inbox,email gelen kutusu, file,dosya, filter,filtre, flag,bayrak, -font,Yazı, -forward,ileri, +font,font, +forward,ilet, green,yeşil, -home,ev, +home,anasayfa icon,ikon, -inbox,Gelen Kutusu, -like,Beğen, -link,Bağlantı, +inbox,gelen kutusu, +like,benzer, +link,link, list,liste, -lock,kilitlemek, +lock,kilitle, logged in,Girildi, -message,Mesaj, -module,Modül, +message,mesaj, +module,modül, move,hareket, music,müzik, new,yeni, now,şimdi, off,kapalı, -one of,Bir, -orange,Portakal, +one of,biri, +orange,portakal, page,sayfa, print,Baskı, purple,mor, @@ -3958,19 +3997,19 @@ refresh,yenile, remove,Kaldır, response,tepki, search,arama, -share,Pay, -stop,durdurmak, -success,başarı, +share,paylaş, +stop,durdur, +success,başarılı, tag,etiket, tags,etiketler, tasks,görevler, time,Zaman, -trash,çöp, -upload,yükleme, -user,Kullanıcı, +trash,çöp kovası, +upload,yükle, +user,kullanıcı, value,değer, -web link,Web Link, -yellow,Sarı, +web link,web link, +yellow,sarı, Not permitted,İzin verilmedi, Add Chart to Dashboard,Gösterge Tablosuna Grafik Ekle, Add to Dashboard,Gösterge Tablosuna Ekle, @@ -3996,7 +4035,7 @@ Developer Mode Only,Yalnızca Geliştirici Modu, Disable User Customization,Kullanıcı Özelleştirmeyi Devre Dışı Bırak, For example: {} Open,Örneğin: {} Aç, Link Cards,Bağlantı Kartları, -Link To,Bağlamak, +Link To,Bağla, Onboarding,Onboarding, Percentage,Yüzde, Pie,Turta, @@ -4007,15 +4046,15 @@ Shortcuts,Kısayollar, X Field,X Alanı, Y Axis,Y Ekseni, workspace,çalışma alanı, -Setup > User,Kurulum> Kullanıcı, -Setup > Customize Form,Kurulum> Formu Özelleştir, -Setup > User Permissions,Kurulum> Kullanıcı İzinleri, +Setup > User,"Kurulum > Kullanıcı", +Setup > Customize Form,"Kurulum > Formu Özelleştir", +Setup > User Permissions,"Kurulum > Kullanıcı İzinleri", "Error connecting to QZ Tray Application...

You need to have QZ Tray application installed and running, to use the Raw Print feature.

Click here to Download and install QZ Tray.
Click here to learn more about Raw Printing.","QZ Tepsi Uygulamasına bağlanırken hata oluştu ...

Raw Print özelliğini kullanmak için QZ Tray uygulamasının kurulu ve çalışıyor olması gerekir.

QZ Tepsisini indirmek ve yüklemek için buraya tıklayın .
Ham Baskı hakkında daha fazla bilgi edinmek için buraya tıklayın .", -No email account associated with the User. Please add an account under User > Email Inbox.,Kullanıcı ile ilişkilendirilmiş e-posta hesabı yok. Lütfen Kullanıcı> E-posta Gelen Kutusu altına bir hesap ekleyin., +"No email account associated with the User. Please add an account under User > Email Inbox.","Kullanıcı ile ilişkilendirilmiş e-posta hesabı yok. Lütfen Kullanıcı> E-posta Gelen Kutusu altına bir hesap ekleyin.", "For comparison, use >5, <10 or =324. For ranges, use 5:10 (for values between 5 & 10).","Karşılaştırma için> 5, <10 veya = 324 kullanın. Aralıklar için 5:10 kullanın (5 ve 10 arasındaki değerler için).", -No default Address Template found. Please create a new one from Setup > Printing and Branding > Address Template.,Varsayılan Adres Şablonu bulunamadı. Lütfen Kurulum> Yazdırma ve Markalama> Adres Şablonu'ndan yeni bir tane oluşturun., -Please setup default Email Account from Setup > Email > Email Account,Lütfen Kurulum> E-posta> E-posta Hesabı'ndan varsayılan E-posta Hesabını ayarlayın, -Email Account not setup. Please create a new Email Account from Setup > Email > Email Account,E-posta Hesabı kurulmadı. Lütfen Kurulum> E-posta> E-posta Hesabı'ndan yeni bir E-posta Hesabı oluşturun, +No default Address Template found. Please create a new one from Setup > Printing and Branding > Address Template.,"Varsayılan Adres Şablonu bulunamadı. Lütfen Kurulum> Yazdırma ve Markalama> Adres Şablonu'ndan yeni bir tane oluşturun.", +Please setup default Email Account from Setup > Email > Email Account,"Lütfen Kurulum> E-posta> E-posta Hesabı'ndan varsayılan E-posta Hesabını ayarlayın", +Email Account not setup. Please create a new Email Account from Setup > Email > Email Account,"E-posta Hesabı kurulmadı. Lütfen Kurulum> E-posta> E-posta Hesabı'ndan yeni bir E-posta Hesabı oluşturun", Attach file,Dosya eki, Contribution Status,Katkı Durumu, Contribution Document Name,Katkı Doküman Adı, @@ -4026,13 +4065,14 @@ Select Language,Dil Seçin, Confirm Translations,Çevirileri Onaylayın, Contributed Translations,Katkıda Bulunan Çeviriler, Show Tags,Etiketleri Göster, +Edit Filters,Filtreleri Düzenle, Do not have permission to access {0} bucket.,{0} paketine erişim izniniz yok., Allow document creation via Email,E-posta yoluyla belge oluşturmaya izin ver, Sender Field,Gönderen Alanı, Logout All Sessions on Password Reset,Parola Sıfırlamayla Tüm Oturumları Kapat, Logout From All Devices After Changing Password,Parolayı Değiştirdikten Sonra Tüm Cihazlardan Çıkış Yapın, -Send Notifications For Documents Followed By Me,Takip Ettiğim Belgeler İçin Bildirimler Gönder, -Send Notifications For Email Threads,E-posta Konuları İçin Bildirimler Gönder, +Send Notifications For Documents Followed By Me,Takip ettiğim Belgeler için Bildirim Gönder, +Send Notifications For Email Threads,E-posta Konuları için Bildirim Gönder, Bypass Restricted IP Address Check If Two Factor Auth Enabled,Sınırlandırılmış IP Adresini Atla İki Faktörlü Kimlik Doğrulama Etkinse Kontrol Edin, Reset LDAP Password,LDAP Parolasını Sıfırla, Confirm New Password,Yeni şifreyi onayla, @@ -4051,7 +4091,7 @@ Value for field {0} is too long in {1}. Length should be lesser than {2} charact Data Too Long,Veri Çok Uzun, via Notification,Bildirim yoluyla, Log in to access this page.,Bu sayfaya erişmek için oturum açın., -Report Document Error,Belge Hatası Bildir, +Report Document Error,Belge Hatasını Bildir, {0} is an invalid Data field.,{0} geçersiz bir Veri alanı., Only Options allowed for Data field are:,Yalnızca Veri alanı için izin verilen Seçenekler şunlardır:, Select a valid Subject field for creating documents from Email,E-postadan belge oluşturmak için geçerli bir Konu alanı seçin, @@ -4066,10 +4106,10 @@ Prepared Report User,Hazırlanan Rapor Kullanıcısı, Scheduler Event,Zamanlayıcı Etkinliği, Select Event Type,Etkinlik Türünü Seçin, Schedule Script,Komut Dosyası Planla, -Duration,süre, +Duration,Süre, Donut,Tatlı çörek, Custom Options,Özel Seçenekler, -"Ex: ""colors"": [""#d1d8dd"", ""#ff5858""]","Ör: "renkler": ["# d1d8dd", "# ff5858"]", +"Ex: ""colors"": [""#d1d8dd"", ""#ff5858""]","Ör: 'renkler': ['# d1d8dd', '# ff5858']", Confirmation Email Template,Onay E-posta Şablonu, Welcome Email Template,Hoş Geldiniz E-posta Şablonu, Schedule Send,Göndermeyi Planla, @@ -4078,7 +4118,7 @@ Advanced Settings,Gelişmiş Ayarlar, Disable Comments,Yorumları Devre Dışı Bırak, Comments on this blog post will be disabled if checked.,Bu blog gönderisine yapılan yorumlar işaretlenirse devre dışı bırakılacaktır., CSS Class,CSS Sınıfı, -Full Width,Tam genişlik, +Full Width,Tam Genişlik, Page Builder,Sayfa Oluşturucu, Page Building Blocks,Sayfa Yapı Taşları, Header and Breadcrumbs,Üstbilgi ve İçerik Kırıntıları, @@ -4089,13 +4129,13 @@ Edit Values,Değerleri Düzenle, Web Template Values,Web Şablonu Değerleri, Add Container,Kapsayıcı Ekle, Web Page View,Web Sayfası Görünümü, -Path,yol, +Path,Yol, Referrer,Yönlendiren, Browser,Tarayıcı, Browser Version,Tarayıcı Sürümü, Web Template Field,Web Şablonu Alanı, Section,Bölüm, -Hide,Saklamak, +Hide,Gizle, Enable In App Website Tracking,Uygulama İçi Web Sitesi İzlemeyi Etkinleştirin, Enable Google Indexing,Google Endekslemeyi Etkinleştir, "To use Google Indexing, enable Google Settings.","Google İndekslemeyi kullanmak için Google Ayarlarını etkinleştirin.", @@ -4140,9 +4180,9 @@ Document is only editable by users with role,Belge yalnızca rolü olan kullanı {0} Page Views,{0} Sayfa Görüntülemeleri, Expand,Genişlet, Collapse,Çöküş, -"Invalid Bearer token, please provide a valid access token with prefix 'Bearer'.","Geçersiz Taşıyıcı belirteci, lütfen 'Taşıyıcı' ön ekiyle geçerli bir erişim belirteci sağlayın.", +"Invalid Bearer token, please provide a valid access token with prefix 'Bearer'.","Geçersiz Taşıyıcı belirteci, lütfen 'Taşıyıcı' ön ekiyle geçerli bir erişim belirteci sağlayın.", "Failed to decode token, please provide a valid base64-encoded token.","Jetonun kodu çözülemedi, lütfen geçerli bir base64 kodlu jeton sağlayın.", -"Invalid token, please provide a valid token with prefix 'Basic' or 'Token'.","Geçersiz simge, lütfen "Temel" veya "Belirteç" ön ekiyle geçerli bir simge sağlayın.", +"Invalid token, please provide a valid token with prefix 'Basic' or 'Token'.","Geçersiz simge, lütfen 'Temel' veya 'Belirteç' ön ekiyle geçerli bir simge sağlayın.", {0} is not a valid Name,"{0}, geçerli bir Ad değil", Your system is being updated. Please refresh again after a few moments.,Sisteminiz güncelleniyor. Lütfen birkaç dakika sonra tekrar yenileyin., {0} {1}: Submitted Record cannot be deleted. You must {2} Cancel {3} it first.,{0} {1}: Gönderilen Kayıt silinemez. Önce onu {2} İptal etmelisiniz {3}., @@ -4161,21 +4201,21 @@ Allow Google Indexing Access,Google Endeksleme Erişimine İzin Ver, Custom Documents,Özel Belgeler, Could not save customization,Özelleştirme kaydedilemedi, Transgender,Transseksüel, -Genderqueer,Cinsiyetçi, +Genderqueer,Cinsiyetsiz, Non-Conforming,Uygun Olmayan, -Prefer not to say,Söylememeyi tercih etmek, +Prefer not to say,Söylememeyi tercih et, Is Billing Contact,Faturalandırmayla İlgili Kişi, Address And Contacts,Adres ve Kişiler, -Lead Conversion Time,Kurşun Dönüş Süresi, +Lead Conversion Time,Aday Dönüş Süresi, Due Date Based On,Tarihli Vade Tarihi, -Phone Number,Telefon numarası, +Phone Number,Telefon Numarası, Linked Documents,Bağlantılı Belgeler, Account SID,Hesap SID, Steps,Adımlar, email,e-posta, Component,Bileşen, Subtitle,Alt yazı, -Global Defaults,Küresel Varsayılanlar, +Global Defaults,Genel Varsayılanlar, Prefix,Önek, Is Public,Herkese Açık, This chart will be available to all Users if this is set,Ayarlanırsa bu grafik tüm Kullanıcılar tarafından kullanılabilir olacaktır, @@ -4192,13 +4232,13 @@ Filters Section,Filtreler Bölümü, Number Card Link,Numara Kartı Bağlantısı, Card,Kart, API Access,API Erişimi, -Access Key Secret,Anahtar Sırrına Erişim, +Access Key Secret,Access Key Secret, S3 Bucket Details,S3 Bucket Ayrıntıları, -Bucket Name,Kova Adı, +Bucket Name,Bucket Adı, Backup Details,Yedekleme Ayrıntıları, Backup Files,Yedekleme dosyaları, Backup public and private files along with the database.,Veritabanıyla birlikte genel ve özel dosyaları yedekleyin., -Set to 0 for no limit on the number of backups taken,Alınan yedekleme sayısında sınır olmaması için 0'a ayarlayın, +Set to 0 for no limit on the number of backups taken,Alınan yedekleme sayısında sınır olmaması için 0'a ayarlayın, Meta Description,Meta Açıklaması, Meta Image,Meta Görüntü, Google Snippet Preview,Google Snippet Önizlemesi, @@ -4213,19 +4253,21 @@ All Time,Her zaman, Select From Date,Tarih Seçiniz, since yesterday,Dünden beri, since last week,Geçen haftadan beri, -since last month,geçen aydan beri, -since last year,geçen yıldan beri, -Show,Göstermek, +since last month,Geçen aydan beri, +since last year,Geçen yıldan beri, +Show,Göster, +Set all private,Tümünü özel olarak ayarla, New Number Card,Yeni Numara Kartı, -Your Shortcuts,Kısayollarınız, +Your Shortcuts,Kısayollar, +You haven't created a {0} yet,Henüz bir {0} oluşturmadınız, You haven't added any Dashboard Charts or Number Cards yet.,Henüz herhangi bir Gösterge Tablosu Grafiği veya Numara Kartı eklemediniz., -Click On Customize to add your first widget,İlk widget'ınızı eklemek için Özelleştir'e tıklayın, +Click On Customize to add your first widget,İlk widget'ınızı eklemek için Özelleştir'e tıklayın, Are you sure you want to reset all customizations?,Tüm özelleştirmeleri sıfırlamak istediğinizden emin misiniz?, "Couldn't save, please check the data you have entered","Kaydedilemedi, lütfen girdiğiniz verileri kontrol edin", Validation Error,Doğrulama Hatası, "You can only upload JPG, PNG, PDF, or Microsoft documents.","Yalnızca JPG, PNG, PDF veya Microsoft belgelerini yükleyebilirsiniz.", -Reverting length to {0} for '{1}' in '{2}'. Setting the length as {3} will cause truncation of data.,"{2}" içindeki "{1}" için uzunluk {0} olarak geri döndürülüyor. Uzunluğu {3} olarak ayarlamak verilerin kesilmesine neden olur., -'{0}' not allowed for type {1} in row {2},{2} satırındaki {1} türü için "{0}" öğesine izin verilmiyor, +Reverting length to {0} for '{1}' in '{2}'. Setting the length as {3} will cause truncation of data.,'{2}' içindeki '{1}' için uzunluk {0} olarak geri döndürülüyor. Uzunluğu {3} olarak ayarlamak verilerin kesilmesine neden olur., +'{0}' not allowed for type {1} in row {2},{2} satırındaki {1} türü için '{0}' öğesine izin verilmiyor, Option {0} for field {1} is not a child table,{1} alanı için {0} seçeneği bir alt tablo değil, Invalid Option,Geçersiz Seçenek, Request Body consists of an invalid JSON structure,İstek Gövdesi geçersiz bir JSON yapısından oluşuyor, @@ -4270,14 +4312,14 @@ Uttar Pradesh,Uttar Pradesh, Uttarakhand,Uttarkand, West Bengal,Batı Bengal, GST State Number,GST Eyalet Numarası, -Import from Google Sheets,Google E-Tablolar'dan içe aktarın, -Must be a publicly accessible Google Sheets URL,Herkes tarafından erişilebilen bir Google E-Tablolar URL'si olmalıdır, +Import from Google Sheets,Google E-Tablolar'dan içe aktarın, +Must be a publicly accessible Google Sheets URL,Herkes tarafından erişilebilen bir Google E-Tablolar URL'si olmalıdır, Refresh Google Sheet,Google Sayfasını Yenile, Import File Errors and Warnings,Dosya Hatalarını ve Uyarılarını İçe Aktar, -"Successfully imported {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.","{1} listeden {0} kayıt başarıyla içe aktarıldı. Hatalı Satırları Dışa Aktar'a tıklayın, hataları düzeltin ve tekrar içe aktarın.", -"Successfully imported {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.","{1} / {0} kaydı başarıyla içe aktarıldı. Hatalı Satırları Dışa Aktar'a tıklayın, hataları düzeltin ve tekrar içe aktarın.", -"Successfully updated {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.","{1} üzerinden {0} kayıt başarıyla güncellendi. Hatalı Satırları Dışa Aktar'a tıklayın, hataları düzeltin ve tekrar içe aktarın.", -"Successfully updated {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.","{1} üzerinden {0} kaydı başarıyla güncellendi. Hatalı Satırları Dışa Aktar'a tıklayın, hataları düzeltin ve tekrar içe aktarın.", +"Successfully imported {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.","{1} listeden {0} kayıt başarıyla içe aktarıldı. Hatalı Satırları Dışa Aktar'a tıklayın, hataları düzeltin ve tekrar içe aktarın.", +"Successfully imported {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.","{1} / {0} kaydı başarıyla içe aktarıldı. Hatalı Satırları Dışa Aktar'a tıklayın, hataları düzeltin ve tekrar içe aktarın.", +"Successfully updated {0} records out of {1}. Click on Export Errored Rows, fix the errors and import again.","{1} üzerinden {0} kayıt başarıyla güncellendi. Hatalı Satırları Dışa Aktar'a tıklayın, hataları düzeltin ve tekrar içe aktarın.", +"Successfully updated {0} record out of {1}. Click on Export Errored Rows, fix the errors and import again.","{1} üzerinden {0} kaydı başarıyla güncellendi. Hatalı Satırları Dışa Aktar'a tıklayın, hataları düzeltin ve tekrar içe aktarın.", Data Import Legacy,Eski Veri İçe Aktarma, Documents restored successfully,Belgeler başarıyla geri yüklendi, Documents that were already restored,Zaten geri yüklenmiş belgeler, @@ -4315,8 +4357,8 @@ Client Code,Müşteri kodu, Report Column,Rapor Sütunu, Report Filter,Rapor Filtresi, Wildcard Filter,Joker Karakter Filtresi, -"Will add ""%"" before and after the query",Sorgudan önce ve sonra "%" eklenir, -"Route: Example ""/desk""",Rota: Örnek "/ masa", +"Will add ""%"" before and after the query",Sorgudan önce ve sonra '%' eklenir, +"Route: Example ""/desk""",Rota: Örnek '/ masa', Enable Onboarding,İlk Katılımı Etkinleştir, Password Reset Link Generation Limit,Parola Sıfırlama Bağlantısı Oluşturma Sınırı, Hourly rate limit for generating password reset links,Şifre sıfırlama bağlantıları oluşturmak için saatlik oran sınırı, @@ -4328,13 +4370,13 @@ Package Document Type,Paket Belge Türü, Include Attachments,Ekleri Dahil Et, Overwrite,Üzerine yaz, Package Publish Target,Paket Yayınlama Hedefi, -Site URL,Site URL'si, +Site URL,Site URL'si, Package Publish Tool,Paket Yayınlama Aracı, Click on the row for accessing filters.,Filtrelere erişmek için sıraya tıklayın., Sites,Siteler, Last Deployed On,Son Dağıtılma Tarihi, Console Log,Konsol Günlüğü, -"Set Default Options for all charts on this Dashboard (Ex: ""colors"": [""#d1d8dd"", ""#ff5858""])","Bu Gösterge Panosundaki tüm grafikler için Varsayılan Seçenekleri ayarlayın (Ör: "renkler": ["# d1d8dd", "# ff5858"])", +"Set Default Options for all charts on this Dashboard (Ex: ""colors"": [""#d1d8dd"", ""#ff5858""])","Bu Gösterge Panosundaki tüm grafikler için Varsayılan Seçenekleri ayarlayın (Ör: 'renkler': ['# d1d8dd', '# ff5858'])", Use Report Chart,Rapor Grafiğini Kullan, Heatmap,Sıcaklık haritası, Dynamic Filters,Dinamik Filtreler, @@ -4343,15 +4385,15 @@ Set Dynamic Filters,Dinamik Filtreler Ayarlayın, Click to Set Dynamic Filters,Dinamik Filtreleri Ayarlamak İçin Tıklayın, Hide Custom DocTypes and Reports,Özel Belge Türlerini ve Raporları Gizle, Checking this will hide custom doctypes and reports cards in Links section,"Bunu işaretlemek, Bağlantılar bölümünde özel belge türlerini ve rapor kartlarını gizleyecektir.", -DocType View,DocType Görünümü, -Which view of the associated DocType should this shortcut take you to?,Bu kısayol sizi ilişkili DocType'ın hangi görünümüne götürmeli?, +DocType View,Belge Türü Görünümü, +Which view of the associated DocType should this shortcut take you to?,Bu kısayol sizi ilişkili DocType'ın hangi görünümüne götürmeli?, List View Settings,Liste Görünümü Ayarları, Maximum Number of Fields,Maksimum Alan Sayısı, Module Onboarding,Modül İlk Katılımı, System managers are allowed by default,Sistem yöneticilerine varsayılan olarak izin verilir, -Documentation URL,Dokümantasyon URL'si, +Documentation URL,Dokümantasyon URL'si, Is Complete,Tamamlandı, -Alert,Uyarmak, +Alert,Uyar, Document Link,Belge Bağlantısı, Attached File,Ekli dosya, Attachment Link,Ek Bağlantısı, @@ -4365,9 +4407,10 @@ Onboarding Step,İlk Katılım Adımı, Is Mandatory,Zorunludur, Is Skipped,Atlandı, Create Entry,Giriş Oluştur, +Create Workspace,Çalışma Alanı Oluştur, Update Settings,Ayarları güncelle, Show Form Tour,Form Turunu Göster, -View Report,Raporu görüntüle, +View Report,Raporu Görüntüle, Go to Page,Sayfaya git, Watch Video,Video izle, Show Full Form?,Tam Form Gösterilsin mi?, @@ -4398,12 +4441,12 @@ Schedule Sending,Göndermeyi Planla, Message (Markdown),Mesaj (Markdown), Message (HTML),Mesaj (HTML), Send Attachments,Ekleri Gönder, -Testing,Test yapmak, +Testing,Test yap, System Notification,Sistem Bildirimi, -WhatsApp,Naber, +WhatsApp,WhatsApp, Twilio Number,Twilio Numarası, -"To use WhatsApp for Business, initialize Twilio Settings.","WhatsApp for Business'ı kullanmak için Twilio Settings'i başlatın .", -"To use Slack Channel, add a Slack Webhook URL.","Slack Kanalını kullanmak için bir Slack Webhook URL'si ekleyin .", +"To use WhatsApp for Business, initialize Twilio Settings.","WhatsApp for Business'ı kullanmak için Twilio Settings'i başlatın .", +"To use Slack Channel, add a Slack Webhook URL.","Slack Kanalını kullanmak için bir Slack Webhook URL'si ekleyin .", Send System Notification,Sistem Bildirimi Gönder, "If enabled, the notification will show up in the notifications dropdown on the top right corner of the navigation bar.","Etkinleştirilirse, bildirim, gezinme çubuğunun sağ üst köşesindeki bildirimler açılır menüsünde görünecektir.", Send To All Assignees,Tüm Atananlara Gönder, @@ -4428,33 +4471,33 @@ Auth Token,Yetkilendirme Jetonu, Read Time,Okuma zamanı, in minutes,dakikalar içinde, Featured,Öne çıkan, -Hide CTA,CTA'yı gizle, +Hide CTA,CTA'yı gizle, "Description for listing page, in plain text, only a couple of lines. (max 200 characters)","Listeleme sayfası için açıklama, düz metin, yalnızca birkaç satır. (en fazla 200 karakter)", Meta Title,Meta Başlığı, Enable Social Sharing,Sosyal Paylaşımı Etkinleştir, -Show CTA in Blog,CTA'yı Blog'da Göster, +Show CTA in Blog,CTA'yı Blog'da Göster, CTA,CTA, CTA Label,CTA Etiketi, -CTA URL,CTA URL'si, +CTA URL,CTA URL'si, Default Portal Home,Varsayılan Portal Ana Sayfası, -"Example: ""/desk""",Örnek: "/ desk", +"Example: ""/desk""",Örnek: '/ desk', Social Link Settings,Sosyal Bağlantı Ayarları, Social Link Type,Sosyal Bağlantı Türü, -facebook,Facebook, -linkedin,Linkedin, -twitter,heyecan, +facebook,facebook, +linkedin,linkedin, +twitter,twitter, "If Icon is set, it will be shown instead of Label","Simge ayarlanmışsa, Etiket yerine gösterilecektir.", Apply Document Permissions,Belge İzinlerini Uygula, "For help see Client Script API and Examples","Yardım için Client Script API ve Örneklerine bakın", Dynamic Route,Dinamik Rota, -Map route parameters into form variables. Example /project/<name>,Rota parametrelerini form değişkenlerine eşleyin. Örnek /project/<name>, +"Map route parameters into form variables. Example /project/<name>","Rota parametrelerini form değişkenlerine eşleyin. Örnek /project/<name>", Context Script,Bağlam Komut Dosyası, -"

Set context before rendering a template. Example:

\n

\ncontext.project = frappe.get_doc(""Project"", frappe.form_dict.name)\n
","

Bir şablonu oluşturmadan önce bağlamı ayarlayın. Misal:

 context.project = frappe.get_doc("Project", frappe.form_dict.name)
", +"

Set context before rendering a template. Example:

\n

\ncontext.project = frappe.get_doc(""Project"", frappe.form_dict.name)\n
","

Bir şablonu oluşturmadan önce bağlamı ayarlayın. Misal:

 context.project = frappe.get_doc('Project', frappe.form_dict.name)
", Title of the page,Sayfanın başlığı, This title will be used as the title of the webpage as well as in meta tags,"Bu başlık, meta etiketlerinde olduğu gibi web sayfasının başlığı olarak kullanılacaktır.", Makes the page public,Sayfayı herkese açık hale getirir, Checking this will publish the page on your website and it'll be visible to everyone.,"Bunu işaretlemek, sayfayı web sitenizde yayınlayacak ve herkes tarafından görülebilecektir.", -URL of the page,Sayfanın URL'si, +URL of the page,Sayfanın URL'si, "This will be automatically generated when you publish the page, you can also enter a route yourself if you wish","Bu, sayfayı yayınladığınızda otomatik olarak oluşturulacaktır, isterseniz kendiniz de bir rota girebilirsiniz", Content type for building the page,Sayfayı oluşturmak için içerik türü, "You can select one from the following,","Aşağıdakilerden birini seçebilirsiniz,", @@ -4476,7 +4519,7 @@ Hide Login,Oturum Açmayı Gizle, Navbar Template,Navbar Şablonu, Navbar Template Values,Navbar Şablon Değerleri, Call To Action,Eylem çağrısı, -Call To Action URL,Harekete Geçirici Mesaj URL'si, +Call To Action URL,Harekete Geçirici Mesaj URL'si, Footer Logo,Altbilgi Logosu, Footer Template,Altbilgi Şablonu, Footer Template Values,Altbilgi Şablon Değerleri, @@ -4493,15 +4536,15 @@ Are you sure you want to save this document?,Bu belgeyi kaydetmek istediğinizde Refresh All,Hepsini yenile, "Level 0 is for document level permissions, higher levels for field level permissions.","Düzey 0, belge düzeyindeki izinler içindir, alan düzeyindeki izinler için daha yüksek düzeylerdir.", Website Analytics,Web Sitesi Analizi, -d,d,Days (Field: Duration) -h,h,Hours (Field: Duration) -m,m,Minutes (Field: Duration) -s,s,Seconds (Field: Duration) +d,Days (Field: Duration), +h,Hours (Field: Duration), +m,Minutes (Field: Duration), +s,Seconds (Field: Duration), Less,Az, Not a valid DocType view:,Geçerli bir DocType görünümü değil:, Unknown View,Bilinmeyen Görünüm, Go Back,Geri dön, -Let's take you back to onboarding,Sizi ilk katılıma geri götürelim, +Let's take you back to onboarding,Sizi işe alıma geri götürelim, Great Job,İyi iş, Looks Great,Harika görünüyor, Looks like you didn't change the value,Görünüşe göre değeri değiştirmemişsin, @@ -4520,7 +4563,7 @@ Reset Fields,Alanları Sıfırla, Select Fields,Alanları Seç, Warning: Unable to find {0} in any table related to {1},Uyarı: {1} ile ilgili herhangi bir tabloda {0} bulunamıyor, Tree view is not available for {0},Ağaç görünümü {0} için kullanılamaz, -Create Card,Kart Oluşturun, +Create Card,Kart Oluştur, Card Label,Kart Etiketi, Reports already in Queue,Zaten Kuyrukta olan raporlar, Proceed Anyway,Yine de Devam Et, @@ -4530,13 +4573,13 @@ Delete and Generate New,Sil ve Yeni Oluştur, Select Fields To Insert,Eklenecek Alanları Seçin, Select Fields To Update,Güncellenecek Alanları Seçin, "This document is already amended, you cannot ammend it again","Bu belge zaten değiştirildi, onu bir daha değiştiremezsiniz", -Add to ToDo,Yapılacaklar'a Ekle, +Add to ToDo,Yapılacaklar'a Ekle, {0} is currently {1},{0} şu anda {1}, {0} are currently {1},{0} şu anda {1}, Currently Replying,Şu anda Yanıtlanıyor, created {0},{0} oluşturuldu, Make a call,Arama yap, -Change,Değişiklik,Coins +Change,Değiştir, Too Many Requests,Çok fazla istek, "Invalid Authorization headers, add a token with a prefix from one of the following: {0}.","Geçersiz Yetkilendirme üstbilgileri, aşağıdakilerden birinden bir öneke sahip bir belirteç ekleyin: {0}.", "Invalid Authorization Type {0}, must be one of {1}.","Geçersiz Yetkilendirme Türü {0}, {1} değerlerinden biri olmalıdır.", @@ -4544,21 +4587,21 @@ Too Many Requests,Çok fazla istek, Invalid Date,Geçersiz tarih, Please select a valid date filter,Lütfen geçerli bir tarih filtresi seçin, Value {0} must be in the valid duration format: d h m s,"{0} değeri, geçerli süre biçiminde olmalıdır: dhms", -Google Sheets URL is invalid or not publicly accessible.,Google E-Tablolar URL'si geçersiz veya herkese açık değil., -"Google Sheets URL must end with ""gid={number}"". Copy and paste the URL from the browser address bar and try again.",Google E-Tablolar URL'si "gid = {sayı}" ile bitmelidir. URL'yi tarayıcının adres çubuğundan kopyalayıp yapıştırın ve tekrar deneyin., +Google Sheets URL is invalid or not publicly accessible.,Google E-Tablolar URL'si geçersiz veya herkese açık değil., +"Google Sheets URL must end with ""gid={number}"". Copy and paste the URL from the browser address bar and try again.",Google E-Tablolar URL'si 'gid = {sayı}' ile bitmelidir. URL'yi tarayıcının adres çubuğundan kopyalayıp yapıştırın ve tekrar deneyin., Incorrect URL,Yanlış URL, -"""{0}"" is not a valid Google Sheets URL",""{0}", geçerli bir Google E-Tablolar URL'si değil", +"""{0}"" is not a valid Google Sheets URL","'{0}', geçerli bir Google E-Tablolar URL'si değil", Duplicate Name,Yinelenen Ad, -"Please check the value of ""Fetch From"" set for field {0}",Lütfen {0} alanı için "Getir" ayarının değerini kontrol edin, +"Please check the value of ""Fetch From"" set for field {0}",Lütfen {0} alanı için 'Getir' ayarının değerini kontrol edin, Wrong Fetch From value,Değerden Yanlış Getirme, -A field with the name '{}' already exists in doctype {}.,{} Doküman türünde '{}' adında bir alan zaten var., +A field with the name '{}' already exists in doctype {}.,{} Doküman türünde '{}' adında bir alan zaten var., Custom Field {0} is created by the Administrator and can only be deleted through the Administrator account.,"Özel Alan {0}, Yönetici tarafından oluşturulur ve yalnızca Yönetici hesabı aracılığıyla silinebilir.", Failed to send {0} Auto Email Report,{0} Otomatik E-posta Raporu gönderilemedi, Test email sent to {0},{0} adresine test e-postası gönderildi, Email queued to {0} recipients,"E-posta, {0} alıcıyla sıraya alındı", Newsletter should have at least one recipient,Haber bülteninin en az bir alıcısı olmalıdır, Please enable Twilio settings to send WhatsApp messages,WhatsApp mesajları göndermek için lütfen Twilio ayarlarını etkinleştirin, -"Not allowed to attach {0} document, please enable Allow Print For {0} in Print Settings","{0} belgesinin eklenmesine izin verilmiyor, lütfen Yazdırma Ayarlarında {0} İçin Yazdırmaya İzin Ver'i etkinleştirin", +"Not allowed to attach {0} document, please enable Allow Print For {0} in Print Settings","{0} belgesinin eklenmesine izin verilmiyor, lütfen Yazdırma Ayarlarında {0} İçin Yazdırmaya İzin Ver'i etkinleştirin", Signup Disabled,Kayıt Devre Dışı, Signups have been disabled for this website.,Bu web sitesi için kayıtlar devre dışı bırakıldı., Open Document,Belgeyi Aç, @@ -4577,9 +4620,9 @@ Skipping Duplicate Column {0},Yinelenen Sütun {0} Atlanıyor, The column {0} has {1} different date formats. Automatically setting {2} as the default format as it is the most common. Please change other values in this column to this format.,{0} sütununda {1} farklı tarih biçimi var. {2} en yaygın biçim olarak varsayılan biçim olarak otomatik olarak ayarlanıyor. Lütfen bu sütundaki diğer değerleri bu biçime değiştirin., You have reached the hourly limit for generating password reset links. Please try again later.,Şifre sıfırlama bağlantıları oluşturmak için saatlik sınıra ulaştınız. Lütfen daha sonra tekrar deneyiniz., Please hide the standard navbar items instead of deleting them,Lütfen standart gezinme çubuğu öğelerini silmek yerine gizleyin, -DocType's name should not start or end with whitespace,DocType'ın adı boşlukla başlamamalı veya bitmemelidir, +DocType's name should not start or end with whitespace,Belge Türünün adı boşlukla başlamamalı veya bitmemelidir, File name cannot have {0},Dosya adı {0} olamaz, -{0} is not a valid file url,"{0}, geçerli bir dosya url'si değil", +{0} is not a valid file url,"{0}, geçerli bir dosya url'si değil", Error Attaching File,Dosya Ekleme Hatası, Please generate keys for the Event Subscriber User {0} first.,Lütfen önce Etkinlik Abone Kullanıcısı {0} için anahtarlar oluşturun., Please set API Key and Secret on the producer and consumer sites first.,Lütfen önce üretici ve tüketici sitelerinde API Anahtarını ve Sırrını ayarlayın., @@ -4593,8 +4636,8 @@ Paytm payment gateway settings,Paytm ödeme ağ geçidi ayarları, Razorpay Signature Verification Failed,Razorpay İmza Doğrulaması Başarısız, Google Drive - Could not locate - {0},Google Drive - Bulunamadı - {0}, "Sync token was invalid and has been resetted, Retry syncing.","Senkronizasyon jetonu geçersizdi ve sıfırlandı, Senkronizasyonu yeniden deneyin.", -Please select another payment method. Paytm does not support transactions in currency '{0}',"Lütfen başka bir ödeme yöntemi seçin. Paytm, '{0}' para birimindeki işlemleri desteklemiyor", -Invalid Account SID or Auth Token.,Geçersiz Hesap SID'si veya Kimlik Doğrulama Jetonu., +Please select another payment method. Paytm does not support transactions in currency '{0}',"Lütfen başka bir ödeme yöntemi seçin. Paytm, '{0}' para birimindeki işlemleri desteklemiyor", +Invalid Account SID or Auth Token.,Geçersiz Hesap SID'si veya Kimlik Doğrulama Jetonu., Please enable twilio settings before sending WhatsApp messages,Lütfen WhatsApp mesajlarını göndermeden önce twilio ayarlarını etkinleştirin, Delivery Failed,Teslimat Başarısız, Twilio WhatsApp Message Error,Twilio WhatsApp Mesaj Hatası, @@ -4620,7 +4663,7 @@ Invalid Credentials,Geçersiz kimlik bilgileri, Print UOM after Quantity,Miktardan Sonra UOM Yazdır, Uncaught Server Exception,Yakalanmamış Sunucu İstisnası, There was an error building this page,Bu sayfa oluşturulurken bir hata meydana geldi, -Hide Traceback,Traceback'i Gizle, +Hide Traceback,Traceback'i Gizle, Value from this field will be set as the due date in the ToDo,"Bu alandaki değer, Yapılacak İşte son tarih olarak ayarlanacaktır", New module created {0},Yeni modül oluşturuldu {0}, "Report has no numeric fields, please change the Report Name","Raporda sayısal alan yok, lütfen Rapor Adını değiştirin", @@ -4639,11 +4682,12 @@ Not permitted to view {0},{0} görüntülenmesine izin verilmiyor, Camera,Kamera, Invalid filter: {0},Geçersiz filtre: {0}, Let's Get Started,Başlayalım, -Reports & Masters,Raporlar ve Ustalar, +Masters & Reports,Ana Veriler & Raporlar, +Reports & Masters,Raporlar & Ana Veriler, New {0} {1} added to Dashboard {2},Gösterge Tablosuna yeni {0} {1} eklendi {2}, New {0} {1} created,Yeni {0} {1} oluşturuldu, New {0} Created,Yeni {0} Oluşturuldu, -"Invalid ""depends_on"" expression set in filter {0}",{0} filtresinde geçersiz "bağımlı_on" ifadesi ayarlandı, +"Invalid ""depends_on"" expression set in filter {0}",{0} filtresinde geçersiz 'bağımlı_on' ifadesi ayarlandı, {0} Reports,{0} Raporlar, There is {0} with the same filters already in the queue:,Sırada zaten aynı filtrelere sahip {0} var:, There are {0} with the same filters already in the queue:,Sırada zaten aynı filtrelere sahip {0} var:, @@ -4664,7 +4708,7 @@ Clear Error log After,Sonra Hata günlüğünü temizle, Clear Activity Log After,Etkinlik Günlüğünü Daha Sonra Temizle, Clear Email Queue After,E-posta Sırasını Sonra Sil, Please save to edit the template.,Lütfen şablonu düzenlemek için kaydedin., -Google Analytics Anonymize IP,Google Analytics IP'yi Anonimleştir, +Google Analytics Anonymize IP,Google Analytics IP'yi Anonimleştir, Incorrect email or password. Please check your login credentials.,Yanlış eposta adresi veya şifre. Lütfen giriş bilgilerinizi kontrol edin., Incorrect Configuration,Yanlış Yapılandırma, You are not allowed to delete Standard Report,Standart Raporu silmenize izin verilmiyor, @@ -4678,13 +4722,13 @@ Cannot delete standard link. You can hide it if you want,Standart bağlantı sil Cannot delete standard action. You can hide it if you want,Standart eylem silinemez. İstersen saklayabilirsin, Applied On,Üzerine uygulanmış, Row Name,Satır Adı, -For DocType Link / DocType Action,DocType Link / DocType Eylemi İçin, +For DocType Link / DocType Action,Belge Türü Link / Belge Türü Eylemi için, Cannot edit filters for standard charts,Standart grafikler için filtreler düzenlenemez, Event Producer Last Update,Etkinlik Yapımcısı Son Güncelleme, -Default for 'Check' type of field {0} must be either '0' or '1',{0} alanının "Kontrol" türü için varsayılan değer "0" veya "1" olmalıdır, +Default for 'Check' type of field {0} must be either '0' or '1',{0} alanının 'Kontrol' türü için varsayılan değer '0' veya '1' olmalıdır, Non Negative,Negatif olmayan, Rules with higher priority number will be applied first.,Önce öncelik numarası daha yüksek olan kurallar uygulanacaktır., -Open URL in a New Tab,URL'yi Yeni Bir Sekmede Aç, +Open URL in a New Tab,URL'yi Yeni Bir Sekmede Aç, Align Right,Sağa Hizala, Loading Filters...,Filtreler Yükleniyor ..., Count Customizations,Özelleştirmeleri Sayma, From ebc32a34f6073a93ae5f69987c409feb14d8e597 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 9 Feb 2023 15:29:06 +0530 Subject: [PATCH 264/407] fix(trim-tables): Exclude virtual doctypes from query --- frappe/model/meta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/model/meta.py b/frappe/model/meta.py index 0f785be0bf..f23dd043c3 100644 --- a/frappe/model/meta.py +++ b/frappe/model/meta.py @@ -772,7 +772,7 @@ def trim_tables(doctype=None, dry_run=False, quiet=False): delete the db field. """ UPDATED_TABLES = {} - filters = {"issingle": 0} + filters = {"issingle": 0, "is_virtual": 0} if doctype: filters["name"] = doctype From 4ccabc4e21eaaefb859c329e4608b2457985b812 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 9 Feb 2023 15:32:06 +0530 Subject: [PATCH 265/407] fix(new-site): Pass sql archive as --source-sql Add --source-sql option as an alias to --source_sql --- frappe/commands/site.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index c78bd4a7c5..25c8c3159d 100644 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -44,7 +44,7 @@ from frappe.exceptions import SiteNotSpecifiedError @click.option( "--force", help="Force restore if site/database already exists", is_flag=True, default=False ) -@click.option("--source_sql", help="Initiate database with a SQL file") +@click.option("--source-sql", "--source_sql", help="Initiate database with a SQL file") @click.option("--install-app", multiple=True, help="Install app after installation") @click.option( "--set-default", is_flag=True, default=False, help="Set the new site as default site" @@ -67,10 +67,13 @@ def new_site( set_default=False, ): "Create a new site" - from frappe.installer import _new_site + from frappe.installer import _new_site, extract_sql_from_archive frappe.init(site=site, new_site=True) + if source_sql: + source_sql = extract_sql_from_archive(source_sql) + _new_site( db_name, site, From d6a41cd2722d1776da774ab2d7521233b5663116 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Thu, 9 Feb 2023 15:49:47 +0530 Subject: [PATCH 266/407] feat: Before/After Request Hooks --- frappe/app.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frappe/app.py b/frappe/app.py index 2fe9991c4c..3acae39fb7 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -119,6 +119,9 @@ def init_request(request): if request.method != "OPTIONS": frappe.local.http_request = HTTPRequest() + for before_request_task in frappe.get_hooks("before_request"): + frappe.call(before_request_task) + def setup_read_only_mode(): """During maintenance_mode reads to DB can still be performed to reduce downtime. This @@ -318,7 +321,10 @@ def handle_exception(e): return response -def after_request(rollback): +def after_request(rollback: bool) -> bool: + for after_request_task in frappe.get_hooks("after_request"): + frappe.call(after_request_task) + # if HTTP method would change server state, commit if necessary if ( frappe.db From 34731d1e9e9fa64fbd64f9266b5ac1b8923f7b02 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 10 Feb 2023 11:37:36 +0530 Subject: [PATCH 267/407] feat: Befor/After Job Hooks --- frappe/utils/background_jobs.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py index 040a57cc11..3c8a731369 100755 --- a/frappe/utils/background_jobs.py +++ b/frappe/utils/background_jobs.py @@ -168,6 +168,10 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, method_name = cstr(method.__name__) frappe.monitor.start("job", method_name, kwargs) + + for before_job_task in frappe.get_hooks("before_job"): + frappe.call(before_job_task, method=method_name, kwargs=kwargs) + try: method(**kwargs) @@ -202,6 +206,9 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, frappe.db.commit() finally: + for after_job_task in frappe.get_hooks("after_job"): + frappe.call(after_job_task, method=method_name, kwargs=kwargs) + # background job hygiene: release file locks if unreleased # if this breaks something, move it to failed jobs alone - gavin@frappe.io for doc in frappe.local.locked_documents: From 0d547cf71c9057a2d93c5c26902c9403a3b718fb Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 10 Feb 2023 12:55:00 +0530 Subject: [PATCH 268/407] test: Add tests for job sanity + hooks --- frappe/tests/test_background_jobs.py | 83 +++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_background_jobs.py b/frappe/tests/test_background_jobs.py index 1b6680eb9b..72660128cf 100644 --- a/frappe/tests/test_background_jobs.py +++ b/frappe/tests/test_background_jobs.py @@ -1,11 +1,19 @@ import time +from contextlib import contextmanager +from unittest.mock import patch from rq import Queue import frappe from frappe.core.doctype.rq_job.rq_job import remove_failed_jobs from frappe.tests.utils import FrappeTestCase -from frappe.utils.background_jobs import generate_qname, get_redis_conn +from frappe.utils.background_jobs import ( + RQ_JOB_FAILURE_TTL, + RQ_RESULTS_TTL, + execute_job, + generate_qname, + get_redis_conn, +) class TestBackgroundJobs(FrappeTestCase): @@ -44,6 +52,79 @@ class TestBackgroundJobs(FrappeTestCase): # lesser is earlier self.assertTrue(high_priority_job.get_position() < low_priority_job.get_position()) + def test_enqueue_call(self): + with patch.object(Queue, "enqueue_call") as mock_enqueue_call: + frappe.enqueue( + "frappe.handler.ping", + queue="short", + timeout=300, + kwargs={"site": frappe.local.site}, + ) + + mock_enqueue_call.assert_called_once_with( + execute_job, + on_success=None, + on_failure=None, + timeout=300, + kwargs={ + "site": frappe.local.site, + "user": "Administrator", + "method": "frappe.handler.ping", + "event": None, + "job_name": "frappe.handler.ping", + "is_async": True, + "kwargs": {"kwargs": {"site": frappe.local.site}}, + }, + at_front=False, + failure_ttl=RQ_JOB_FAILURE_TTL, + result_ttl=RQ_RESULTS_TTL, + ) + + def test_job_hooks(self): + self.addCleanup(lambda: _test_JOB_HOOK.clear()) + with freeze_local() as locals, frappe.init_site(locals.site), patch( + "frappe.get_hooks", patch_job_hooks + ): + frappe.connect() + self.assertIsNone(_test_JOB_HOOK.get("before_job")) + r = execute_job( + site=frappe.local.site, + user="Administrator", + method="frappe.handler.ping", + event=None, + job_name="frappe.handler.ping", + is_async=True, + kwargs={}, + ) + self.assertEqual(r, "pong") + self.assertLess(_test_JOB_HOOK.get("before_job"), _test_JOB_HOOK.get("after_job")) + def fail_function(): return 1 / 0 + + +_test_JOB_HOOK = {} + + +def before_job(*args, **kwargs): + _test_JOB_HOOK["before_job"] = time.time() + + +def after_job(*args, **kwargs): + _test_JOB_HOOK["after_job"] = time.time() + + +@contextmanager +def freeze_local(): + locals = frappe.local + frappe.local = frappe.Local() + yield locals + frappe.local = locals + + +def patch_job_hooks(event: str): + return { + "before_job": ["frappe.tests.test_background_jobs.before_job"], + "after_job": ["frappe.tests.test_background_jobs.after_job"], + }[event] From 55b31fcdfae7cabfa55b7a55bea29228118295e1 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 10 Feb 2023 13:22:27 +0530 Subject: [PATCH 269/407] test: Added test for before/after request hook --- frappe/tests/test_api.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/frappe/tests/test_api.py b/frappe/tests/test_api.py index 1085f4c39e..c345d8fbcf 100644 --- a/frappe/tests/test_api.py +++ b/frappe/tests/test_api.py @@ -2,6 +2,7 @@ import sys from contextlib import contextmanager from random import choice from threading import Thread +from time import time from unittest.mock import patch import requests @@ -306,3 +307,36 @@ class TestReadOnlyMode(FrappeAPITestCase): response = self.post(self.REQ_PATH, {"description": frappe.mock("paragraph"), "sid": self.sid}) self.assertEqual(response.status_code, 503) self.assertEqual(response.json["exc_type"], "InReadOnlyMode") + + +class TestWSGIApp(FrappeAPITestCase): + def test_request_hooks(self): + self.addCleanup(lambda: _test_REQ_HOOK.clear()) + get_hooks = frappe.get_hooks + + def patch_request_hooks(event: str, *args, **kwargs): + patched_hooks = { + "before_request": ["frappe.tests.test_api.before_request"], + "after_request": ["frappe.tests.test_api.after_request"], + } + if event not in patched_hooks: + return get_hooks(event, *args, **kwargs) + return patched_hooks[event] + + with patch("frappe.get_hooks", patch_request_hooks): + self.assertIsNone(_test_REQ_HOOK.get("before_request")) + self.assertIsNone(_test_REQ_HOOK.get("after_request")) + res = self.get("/api/method/ping") + self.assertEqual(res.json, {"message": "pong"}) + self.assertLess(_test_REQ_HOOK.get("before_request"), _test_REQ_HOOK.get("after_request")) + + +_test_REQ_HOOK = {} + + +def before_request(*args, **kwargs): + _test_REQ_HOOK["before_request"] = time() + + +def after_request(*args, **kwargs): + _test_REQ_HOOK["after_request"] = time() From 83f3cf1991b4c26ba0fbd5e937670e1cfb9e3afb Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 10 Feb 2023 13:23:24 +0530 Subject: [PATCH 270/407] fix(background_jobs): Pass retval in execute_job --- frappe/utils/background_jobs.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py index 3c8a731369..2ca34141a9 100755 --- a/frappe/utils/background_jobs.py +++ b/frappe/utils/background_jobs.py @@ -153,6 +153,7 @@ def run_doc_method(doctype, name, doc_method, **kwargs): def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, retry=0): """Executes job in a worker, performs commit/rollback and logs if there is any error""" + retval = None if is_async: frappe.connect(site) if os.environ.get("CI"): @@ -173,7 +174,7 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, frappe.call(before_job_task, method=method_name, kwargs=kwargs) try: - method(**kwargs) + retval = method(**kwargs) except (frappe.db.InternalError, frappe.RetryBackgroundJobError) as e: frappe.db.rollback() @@ -204,6 +205,7 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, else: frappe.db.commit() + return retval finally: for after_job_task in frappe.get_hooks("after_job"): From ea62156a6d028ea764ff8a9aabe7406313e1ab89 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 10 Feb 2023 13:28:50 +0530 Subject: [PATCH 271/407] fix: Add request, job events in hooks boilerplate --- frappe/utils/boilerplate.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frappe/utils/boilerplate.py b/frappe/utils/boilerplate.py index ee75c672a8..1cd57f4695 100644 --- a/frappe/utils/boilerplate.py +++ b/frappe/utils/boilerplate.py @@ -458,6 +458,15 @@ app_license = "{app_license}" # ignore_links_on_delete = ["Communication", "ToDo"] +# Request Events +# ---------------- +# before_request = ["{app_name}.utils.before_request"] +# after_request = ["{app_name}.utils.after_request"] + +# Job Events +# ---------- +# before_job = ["{app_name}.utils.before_job"] +# after_job = ["{app_name}.utils.after_job"] # User Data Protection # -------------------- From 2ada98fbdb1d93e7747d15977769b5cc8a1f030c Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 10 Feb 2023 13:31:46 +0530 Subject: [PATCH 272/407] fix: Pass result of job to after_job hooks --- frappe/utils/background_jobs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py index 2ca34141a9..a106c3beba 100755 --- a/frappe/utils/background_jobs.py +++ b/frappe/utils/background_jobs.py @@ -209,7 +209,7 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, finally: for after_job_task in frappe.get_hooks("after_job"): - frappe.call(after_job_task, method=method_name, kwargs=kwargs) + frappe.call(after_job_task, method=method_name, kwargs=kwargs, result=retval) # background job hygiene: release file locks if unreleased # if this breaks something, move it to failed jobs alone - gavin@frappe.io From 6b84c9ccf5fc3c389223f37cf4fb1865be430448 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 10 Feb 2023 14:38:50 +0530 Subject: [PATCH 273/407] feat: Check scheduler status via CLI new: `bench --site scheduler status` --- frappe/commands/scheduler.py | 38 +++++++++++++++--------------------- frappe/utils/scheduler.py | 8 +++++--- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/frappe/commands/scheduler.py b/frappe/commands/scheduler.py index a6610c9213..7ced1e4d11 100755 --- a/frappe/commands/scheduler.py +++ b/frappe/commands/scheduler.py @@ -74,37 +74,31 @@ def disable_scheduler(context): @click.command("scheduler") @click.option("--site", help="site name") -@click.argument("state", type=click.Choice(["pause", "resume", "disable", "enable"])) +@click.argument("state", type=click.Choice(["pause", "resume", "disable", "enable", "status"])) @pass_context -def scheduler(context, state, site=None): +def scheduler(context, state: str, site: str | None = None): """Control scheduler state.""" + import frappe import frappe.utils.scheduler from frappe.installer import update_site_config - if not site: - site = get_site(context) + site = site or get_site(context) - try: - frappe.init(site=site) - - if state == "pause": - update_site_config("pause_scheduler", 1) - elif state == "resume": - update_site_config("pause_scheduler", 0) - elif state == "disable": - frappe.connect() - frappe.utils.scheduler.disable_scheduler() - frappe.db.commit() - elif state == "enable": - frappe.connect() - frappe.utils.scheduler.enable_scheduler() - frappe.db.commit() + with frappe.init_site(site=site): + match state: + case "status": + frappe.connect() + status = "disabled" if frappe.utils.scheduler.is_scheduler_inactive(verbose=verbose) else "enabled" + return print(output[format].format(status=status, site=site)) + case "pause" | "resume": + update_site_config("pause_scheduler", state == "pause") + case "enable" | "disable": + frappe.connect() + frappe.utils.scheduler.toggle_scheduler(state == "enable") + frappe.db.commit() print(f"Scheduler {state}d for site {site}") - finally: - frappe.destroy() - @click.command("set-maintenance-mode") @click.option("--site", help="site name") diff --git a/frappe/utils/scheduler.py b/frappe/utils/scheduler.py index 03c1b37a43..146a7fa244 100755 --- a/frappe/utils/scheduler.py +++ b/frappe/utils/scheduler.py @@ -107,16 +107,18 @@ def is_scheduler_inactive() -> bool: return False -def is_scheduler_disabled() -> bool: +def is_scheduler_disabled(verbose=True) -> bool: if frappe.conf.disable_scheduler: - cprint(f"{frappe.local.site}: frappe.conf.disable_scheduler is SET") + if verbose: + cprint(f"{frappe.local.site}: frappe.conf.disable_scheduler is SET") return True scheduler_disabled = not frappe.utils.cint( frappe.db.get_single_value("System Settings", "enable_scheduler") ) if scheduler_disabled: - cprint(f"{frappe.local.site}: SystemSettings.enable_scheduler is UNSET") + if verbose: + cprint(f"{frappe.local.site}: SystemSettings.enable_scheduler is UNSET") return scheduler_disabled From 4738a1422da05e714f201368a90f936a0d743ee2 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 10 Feb 2023 14:39:16 +0530 Subject: [PATCH 274/407] fix: Add format, verbose options to scheduler --- frappe/commands/scheduler.py | 17 ++++++++++++++--- frappe/utils/scheduler.py | 10 ++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/frappe/commands/scheduler.py b/frappe/commands/scheduler.py index 7ced1e4d11..6365ecdd26 100755 --- a/frappe/commands/scheduler.py +++ b/frappe/commands/scheduler.py @@ -75,8 +75,12 @@ def disable_scheduler(context): @click.command("scheduler") @click.option("--site", help="site name") @click.argument("state", type=click.Choice(["pause", "resume", "disable", "enable", "status"])) +@click.option( + "--format", "-f", default="text", type=click.Choice(["json", "text"]), help="Output format" +) +@click.option("--verbose", "-v", is_flag=True, help="Verbose output") @pass_context -def scheduler(context, state: str, site: str | None = None): +def scheduler(context, state: str, format: str, verbose: bool = False, site: str | None = None): """Control scheduler state.""" import frappe import frappe.utils.scheduler @@ -84,11 +88,18 @@ def scheduler(context, state: str, site: str | None = None): site = site or get_site(context) + output = { + "text": "Scheduler is {status} for site {site}", + "json": '{{"status": "{status}", "site": "{site}"}}', + } + with frappe.init_site(site=site): match state: case "status": frappe.connect() - status = "disabled" if frappe.utils.scheduler.is_scheduler_inactive(verbose=verbose) else "enabled" + status = ( + "disabled" if frappe.utils.scheduler.is_scheduler_inactive(verbose=verbose) else "enabled" + ) return print(output[format].format(status=status, site=site)) case "pause" | "resume": update_site_config("pause_scheduler", state == "pause") @@ -97,7 +108,7 @@ def scheduler(context, state: str, site: str | None = None): frappe.utils.scheduler.toggle_scheduler(state == "enable") frappe.db.commit() - print(f"Scheduler {state}d for site {site}") + print(output[format].format(status=f"{state}d", site=site)) @click.command("set-maintenance-mode") diff --git a/frappe/utils/scheduler.py b/frappe/utils/scheduler.py index 146a7fa244..8cda71ee9a 100755 --- a/frappe/utils/scheduler.py +++ b/frappe/utils/scheduler.py @@ -92,16 +92,18 @@ def enqueue_events(site: str) -> list[str] | None: return enqueued_jobs -def is_scheduler_inactive() -> bool: +def is_scheduler_inactive(verbose=True) -> bool: if frappe.local.conf.maintenance_mode: - cprint(f"{frappe.local.site}: Maintenance mode is ON") + if verbose: + cprint(f"{frappe.local.site}: Maintenance mode is ON") return True if frappe.local.conf.pause_scheduler: - cprint(f"{frappe.local.site}: frappe.conf.pause_scheduler is SET") + if verbose: + cprint(f"{frappe.local.site}: frappe.conf.pause_scheduler is SET") return True - if is_scheduler_disabled(): + if is_scheduler_disabled(verbose=verbose): return True return False From 18eabf515312cda03df9c12188c471ea9c301385 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 10 Feb 2023 14:39:39 +0530 Subject: [PATCH 275/407] fix(dx): Don't reload web workers if tests are changed in dev server --- frappe/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/frappe/app.py b/frappe/app.py index 2fe9991c4c..03414646ef 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -376,6 +376,7 @@ def serve( "0.0.0.0", int(port), application, + exclude_patterns=["test_*"], use_reloader=False if in_test_env else not no_reload, use_debugger=not in_test_env, use_evalex=not in_test_env, From 32cf13cb29a9b38fd9302de33ebab6e4000a2134 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 10 Feb 2023 14:45:39 +0530 Subject: [PATCH 276/407] chore: Cleanup imports --- frappe/commands/scheduler.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/frappe/commands/scheduler.py b/frappe/commands/scheduler.py index 6365ecdd26..36fa81f8a5 100755 --- a/frappe/commands/scheduler.py +++ b/frappe/commands/scheduler.py @@ -5,7 +5,6 @@ import click import frappe from frappe.commands import get_site, pass_context from frappe.exceptions import SiteNotSpecifiedError -from frappe.utils import cint @click.command("trigger-scheduler-event", help="Trigger a scheduler event") @@ -83,8 +82,7 @@ def disable_scheduler(context): def scheduler(context, state: str, format: str, verbose: bool = False, site: str | None = None): """Control scheduler state.""" import frappe - import frappe.utils.scheduler - from frappe.installer import update_site_config + from frappe.utils.scheduler import is_scheduler_inactive, toggle_scheduler site = site or get_site(context) @@ -97,15 +95,15 @@ def scheduler(context, state: str, format: str, verbose: bool = False, site: str match state: case "status": frappe.connect() - status = ( - "disabled" if frappe.utils.scheduler.is_scheduler_inactive(verbose=verbose) else "enabled" - ) + status = "disabled" if is_scheduler_inactive(verbose=verbose) else "enabled" return print(output[format].format(status=status, site=site)) case "pause" | "resume": + from frappe.installer import update_site_config + update_site_config("pause_scheduler", state == "pause") case "enable" | "disable": frappe.connect() - frappe.utils.scheduler.toggle_scheduler(state == "enable") + toggle_scheduler(state == "enable") frappe.db.commit() print(output[format].format(status=f"{state}d", site=site)) From c525268084059df07d01e20cd1c6c723cf942050 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Feb 2023 19:52:47 +0530 Subject: [PATCH 277/407] chore(deps): bump gabrielfalcao/pyenv-action from 10 to 13 (#19940) Bumps [gabrielfalcao/pyenv-action](https://github.com/gabrielfalcao/pyenv-action) from 10 to 13. - [Release notes](https://github.com/gabrielfalcao/pyenv-action/releases) - [Commits](https://github.com/gabrielfalcao/pyenv-action/compare/v10...v13) --- updated-dependencies: - dependency-name: gabrielfalcao/pyenv-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> [skip ci] --- .github/workflows/patch-mariadb-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index 4b487d2aea..6af9e273a2 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -62,7 +62,7 @@ jobs: fi - name: Setup Python - uses: "gabrielfalcao/pyenv-action@v10" + uses: "gabrielfalcao/pyenv-action@v13" with: versions: 3.10:latest, 3.7:latest From 1f6fdebff6af96b3bd80487d9e69bd38f65a6f22 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 10 Feb 2023 20:04:54 +0530 Subject: [PATCH 278/407] fix: login before check should be inclusive (#19974) e.g. if login_before hour is 6 and it's 6:30 then it should be blocked. related :) - https://fhur.me/posts/always-use-closed-open-intervals --- frappe/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/auth.py b/frappe/auth.py index 3321784ce2..f1cdac52bd 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -307,7 +307,7 @@ class LoginManager: current_hour = int(now_datetime().strftime("%H")) - if login_before and current_hour > login_before: + if login_before and current_hour >= login_before: frappe.throw(_("Login not allowed at this time"), frappe.AuthenticationError) if login_after and current_hour < login_after: From b1bffd826ceb1870e32fef1f192d214d7f07d620 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 10 Feb 2023 21:28:30 +0530 Subject: [PATCH 279/407] chore(mergify): remove dead branches [skip ci] --- .mergify.yml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/.mergify.yml b/.mergify.yml index b74648a8f5..0881dd591b 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -71,23 +71,3 @@ pull_request_rules: assignees: - "{{ author }}" - - - name: backport to version-13-pre-release - conditions: - - label="backport version-13-pre-release" - actions: - backport: - branches: - - version-13-pre-release - assignees: - - "{{ author }}" - - - name: backport to version-12-hotfix - conditions: - - label="backport version-12-hotfix" - actions: - backport: - branches: - - version-12-hotfix - assignees: - - "{{ author }}" From d0cc2043c9ad02a30c0262701e0ff5d2c4d4d36a Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Fri, 10 Feb 2023 22:26:44 +0530 Subject: [PATCH 280/407] fix: String formatting index in error message (#19977) --- frappe/desk/form/meta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/form/meta.py b/frappe/desk/form/meta.py index 94f3842ab7..62a9c89c81 100644 --- a/frappe/desk/form/meta.py +++ b/frappe/desk/form/meta.py @@ -207,7 +207,7 @@ class FormMeta(Meta): if df.get("is_custom_field"): custom_field_link = get_link_to_form("Custom Field", df.name) - msg += " " + _("Please delete the field from {2} or add the required doctype.").format( + msg += " " + _("Please delete the field from {0} or add the required doctype.").format( custom_field_link ) From aae3bac0b11d9a896ff35a2e1aefd6e28ad6bcc5 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 13 Feb 2023 11:34:07 +0530 Subject: [PATCH 281/407] chore: hard pin patch python versions [skip ci] --- .github/workflows/patch-mariadb-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index 6af9e273a2..ae148905ad 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -64,7 +64,7 @@ jobs: - name: Setup Python uses: "gabrielfalcao/pyenv-action@v13" with: - versions: 3.10:latest, 3.7:latest + versions: 3.10.9, 3.7.16 - name: Setup Node uses: actions/setup-node@v3 From c3e526b4751693ca4ce08888313d99e11fbdcf1a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 13 Feb 2023 13:37:05 +0530 Subject: [PATCH 282/407] fix: Maintain checkbox selection on Bulk Edit List Views --- frappe/public/js/frappe/list/list_view.js | 1 - 1 file changed, 1 deletion(-) diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index 4e74710edf..f4f46aaccd 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -1832,7 +1832,6 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { this.disable_list_update = true; bulk_operations.edit(this.get_checked_items(true), field_mappings, () => { this.disable_list_update = false; - this.clear_checked_items(); this.refresh(); }); }, From 01cc586e20248b62ac9039bfbe7a9bf10268e0c7 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 13 Feb 2023 13:42:59 +0530 Subject: [PATCH 283/407] fix: Use yarn to figure out bin path instead of npm Npm v9 doesn't have bin anymore. This causes run-ui-tests to fail refs: - https://docs.npmjs.com/cli/v8/commands/npm-bin - https://yarnpkg.com/cli/bin --- frappe/commands/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 5ec0b54828..f41cca3c57 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -908,7 +908,7 @@ def run_ui_tests( os.chdir(app_base_path) - node_bin = subprocess.getoutput("npm bin") + node_bin = subprocess.getoutput("yarn bin") cypress_path = f"{node_bin}/cypress" drag_drop_plugin_path = f"{node_bin}/../@4tw/cypress-drag-drop" real_events_plugin_path = f"{node_bin}/../cypress-real-events" From 8180f926e1fb82de66fc9f7e10c70139c08154e1 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Mon, 13 Feb 2023 09:57:23 +0100 Subject: [PATCH 284/407] fix: ignore permission for marking Note as seen (#19939) --- frappe/desk/doctype/note/note.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/doctype/note/note.py b/frappe/desk/doctype/note/note.py index c0a37d5f44..4e11c1c055 100644 --- a/frappe/desk/doctype/note/note.py +++ b/frappe/desk/doctype/note/note.py @@ -30,7 +30,7 @@ def mark_as_seen(note): note = frappe.get_doc("Note", note) if frappe.session.user not in [d.user for d in note.seen_by]: note.append("seen_by", {"user": frappe.session.user}) - note.save(ignore_version=True) + note.save(ignore_version=True, ignore_permissions=True) def get_permission_query_conditions(user): From 8130153ce6b2a74887ee0c7f59c81d430e719d02 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Mon, 13 Feb 2023 10:04:35 +0100 Subject: [PATCH 285/407] fix: quote provider name (#19604) * fix: quote provider name * fix: escape icon in get_icon_html --- frappe/utils/html_utils.py | 7 +++++-- frappe/www/login.py | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/frappe/utils/html_utils.py b/frappe/utils/html_utils.py index c34c4fd188..7edf6556c9 100644 --- a/frappe/utils/html_utils.py +++ b/frappe/utils/html_utils.py @@ -4,6 +4,7 @@ import re from bleach_allowlist import bleach_allowlist import frappe +from frappe.utils.data import escape_html EMOJI_PATTERN = re.compile( "(\ud83d[\ude00-\ude4f])|" @@ -204,10 +205,12 @@ def get_icon_html(icon, small=False): if is_image(icon): return ( - f'' if small else f'' + f"" + if small + else f"" ) else: - return f"" + return f"" def unescape_html(value): diff --git a/frappe/www/login.py b/frappe/www/login.py index 97ceb01c6e..8529b03bf6 100644 --- a/frappe/www/login.py +++ b/frappe/www/login.py @@ -6,9 +6,9 @@ import frappe.utils from frappe import _ from frappe.auth import LoginManager from frappe.integrations.doctype.ldap_settings.ldap_settings import LDAPSettings -from frappe.integrations.oauth2_logins import decoder_compat from frappe.rate_limiter import rate_limit from frappe.utils import cint, get_url +from frappe.utils.data import escape_html from frappe.utils.html_utils import get_icon_html from frappe.utils.jinja import guess_is_path from frappe.utils.oauth import get_oauth2_authorize_url, get_oauth_keys, redirect_post_login @@ -72,7 +72,7 @@ def get_context(context): if provider.provider_name == "Custom": icon = get_icon_html(provider.icon, small=True) else: - icon = f"{provider.provider_name}" + icon = f"{escape_html(provider.provider_name)!r}" if provider.client_id and provider.base_url and get_oauth_keys(provider.name): context.provider_logins.append( From c337e532d88b85c1f9ccde5e73bf7fea9d594404 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 13 Feb 2023 14:55:23 +0530 Subject: [PATCH 286/407] test: Add test for db-console pass extra params to client Added test for https://github.com/frappe/frappe/pull/19809 --- frappe/tests/test_commands.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 4a10484d1d..66f797d168 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -773,3 +773,9 @@ class TestDBCli(BaseTestCommands): def test_db_cli(self): self.execute("bench --site {site} db-console", kwargs={"cmd_input": rb"\q"}) self.assertEqual(self.returncode, 0) + + @run_only_if(db_type_is.MARIADB) + def test_db_cli_with_sql(self): + self.execute("bench --site {site} db-console -e 'select 1'") + self.assertEqual(self.returncode, 0) + self.assertIn("1", self.stdout) From 7bfd20ce87ee65f8b6829890c5de8e96ab16abd3 Mon Sep 17 00:00:00 2001 From: Jannat Patel <31363128+pateljannat@users.noreply.github.com> Date: Mon, 13 Feb 2023 14:57:56 +0530 Subject: [PATCH 287/407] fix: event date exception (#19955) --- frappe/desk/doctype/event/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/desk/doctype/event/event.py b/frappe/desk/doctype/event/event.py index 53a5a50cec..5013c4fb3d 100644 --- a/frappe/desk/doctype/event/event.py +++ b/frappe/desk/doctype/event/event.py @@ -363,7 +363,7 @@ def get_events(start, end, user=None, for_reminder=False, filters=None) -> list[ # last day of month issue, start from prev month! try: getdate(date) - except ValueError: + except Exception: date = date.split("-") date = date[0] + "-" + str(cint(date[1]) - 1) + "-" + date[2] From 2c348af4f7a9b2332080d5b4556666a5ccbd6e36 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Mon, 13 Feb 2023 15:08:56 +0530 Subject: [PATCH 288/407] test: Add tests for scheduler cli --- frappe/tests/test_commands.py | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/frappe/tests/test_commands.py b/frappe/tests/test_commands.py index 66f797d168..7bda577bb6 100644 --- a/frappe/tests/test_commands.py +++ b/frappe/tests/test_commands.py @@ -33,6 +33,7 @@ from frappe.tests.utils import FrappeTestCase, timeout from frappe.utils import add_to_date, get_bench_path, get_bench_relative_path, now from frappe.utils.backups import BackupGenerator, fetch_latest_backups from frappe.utils.jinja_globals import bundled_asset +from frappe.utils.scheduler import enable_scheduler, is_scheduler_inactive _result: Result | None = None TEST_SITE = "commands-site-O4PN2QKA.test" # added random string tag to avoid collisions @@ -779,3 +780,46 @@ class TestDBCli(BaseTestCommands): self.execute("bench --site {site} db-console -e 'select 1'") self.assertEqual(self.returncode, 0) self.assertIn("1", self.stdout) + + +class TestSchedulerCLI(BaseTestCommands): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.is_scheduler_active = not is_scheduler_inactive() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + if cls.is_scheduler_active: + enable_scheduler() + + def test_scheduler_status(self): + self.execute("bench --site {site} scheduler status") + self.assertEqual(self.returncode, 0) + self.assertRegex(self.stdout, r"Scheduler is (disabled|enabled) for site .*") + + self.execute("bench --site {site} scheduler status -f json") + parsed_output = frappe.parse_json(self.stdout) + self.assertEqual(self.returncode, 0) + self.assertIsInstance(parsed_output, dict) + self.assertIn("status", parsed_output) + self.assertIn("site", parsed_output) + + def test_scheduler_enable_disable(self): + self.execute("bench --site {site} scheduler disable") + self.assertEqual(self.returncode, 0) + self.assertRegex(self.stdout, r"Scheduler is disabled for site .*") + + self.execute("bench --site {site} scheduler enable") + self.assertEqual(self.returncode, 0) + self.assertRegex(self.stdout, r"Scheduler is enabled for site .*") + + def test_scheduler_pause_resume(self): + self.execute("bench --site {site} scheduler pause") + self.assertEqual(self.returncode, 0) + self.assertRegex(self.stdout, r"Scheduler is paused for site .*") + + self.execute("bench --site {site} scheduler resume") + self.assertEqual(self.returncode, 0) + self.assertRegex(self.stdout, r"Scheduler is resumed for site .*") From 29be4a544ebead4e5379eac1c13c4be2ed54ed8d Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 13 Feb 2023 16:17:18 +0530 Subject: [PATCH 289/407] ci: broken patch tests (#20010) * Revert "chore: hard pin patch python versions" This reverts commit aae3bac0b11d9a896ff35a2e1aefd6e28ad6bcc5. * Revert "chore(deps): bump gabrielfalcao/pyenv-action from 10 to 13 (#19940)" This reverts commit c525268084059df07d01e20cd1c6c723cf942050. --- .github/workflows/patch-mariadb-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/patch-mariadb-tests.yml b/.github/workflows/patch-mariadb-tests.yml index ae148905ad..4b487d2aea 100644 --- a/.github/workflows/patch-mariadb-tests.yml +++ b/.github/workflows/patch-mariadb-tests.yml @@ -62,9 +62,9 @@ jobs: fi - name: Setup Python - uses: "gabrielfalcao/pyenv-action@v13" + uses: "gabrielfalcao/pyenv-action@v10" with: - versions: 3.10.9, 3.7.16 + versions: 3.10:latest, 3.7:latest - name: Setup Node uses: actions/setup-node@v3 From 91e0d1a4398bd72bb0822cd7b4e7bdf847861552 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 13 Feb 2023 16:17:34 +0530 Subject: [PATCH 290/407] fix: Migrate color fields to color doctype (#20011) --- .../generate_theme_files_in_public_folder.py | 1 - .../v13_0/website_theme_custom_scss.py | 34 ++++++++++++------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/frappe/patches/v13_0/generate_theme_files_in_public_folder.py b/frappe/patches/v13_0/generate_theme_files_in_public_folder.py index f05cf46c74..62c7bcdfde 100644 --- a/frappe/patches/v13_0/generate_theme_files_in_public_folder.py +++ b/frappe/patches/v13_0/generate_theme_files_in_public_folder.py @@ -12,7 +12,6 @@ def execute(): for theme in themes: doc = frappe.get_doc("Website Theme", theme.name) try: - doc.generate_bootstrap_theme() doc.save() except Exception: print("Ignoring....") diff --git a/frappe/patches/v13_0/website_theme_custom_scss.py b/frappe/patches/v13_0/website_theme_custom_scss.py index d1a5e11228..d72b763bd9 100644 --- a/frappe/patches/v13_0/website_theme_custom_scss.py +++ b/frappe/patches/v13_0/website_theme_custom_scss.py @@ -8,21 +8,31 @@ def execute(): for theme in frappe.get_all("Website Theme"): doc = frappe.get_doc("Website Theme", theme.name) + setup_color_record(doc) if not doc.get("custom_scss") and doc.theme_scss: # move old theme to new theme doc.custom_scss = doc.theme_scss - - if doc.background_color: - setup_color_record(doc.background_color) - doc.save() -def setup_color_record(color): - frappe.get_doc( - { - "doctype": "Color", - "__newname": color, - "color": color, - } - ).save() +def setup_color_record(doc): + color_fields = [ + "primary_color", + "text_color", + "light_color", + "dark_color", + "background_color", + ] + + for color_field in color_fields: + color_code = doc.get(color_field) + if not color_code or frappe.db.exists("Color", color_code): + continue + + frappe.get_doc( + { + "doctype": "Color", + "__newname": color_code, + "color": color_code, + } + ).insert() From c69c040186e76c5798ac9c2d5d15b545a61eda27 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 13 Feb 2023 18:26:09 +0530 Subject: [PATCH 291/407] chore: typo --- frappe/printing/doctype/print_format/print_format.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/printing/doctype/print_format/print_format.js b/frappe/printing/doctype/print_format/print_format.js index dfe5633f65..64cd36a727 100644 --- a/frappe/printing/doctype/print_format/print_format.js +++ b/frappe/printing/doctype/print_format/print_format.js @@ -41,7 +41,7 @@ frappe.ui.form.on("Print Format", { } if (frappe.model.can_write("Customize Form")) { frappe.model.with_doctype(frm.doc.doc_type, function () { - let current_format = frappe.get_meta(frm.doc.DocType).default_print_format; + let current_format = frappe.get_meta(frm.doc.doc_type).default_print_format; if (current_format == frm.doc.name) { return; } From e9c57ee76e3d3f80a5756489061bebf8e5a12930 Mon Sep 17 00:00:00 2001 From: Rutwik Hiwalkar <50401596+rutwikhdev@users.noreply.github.com> Date: Tue, 14 Feb 2023 12:04:28 +0530 Subject: [PATCH 292/407] revert: subscription management (#19998) * chore: add namespaced subscription conf to boot info * revert: https://github.com/frappe/frappe/pull/18263 * clean: remove daily hook for creating manage subscription btn --- frappe/boot.py | 6 +- frappe/hooks.py | 1 - frappe/patches.txt | 1 - ...manage_subscriptions_in_navbar_settings.py | 25 ------ frappe/public/js/desk.bundle.js | 1 - .../js/frappe/ui/toolbar/subscription.js | 80 ------------------- frappe/utils/subscription.py | 35 -------- 7 files changed, 3 insertions(+), 146 deletions(-) delete mode 100644 frappe/patches/v14_0/add_manage_subscriptions_in_navbar_settings.py delete mode 100644 frappe/public/js/frappe/ui/toolbar/subscription.js delete mode 100644 frappe/utils/subscription.py diff --git a/frappe/boot.py b/frappe/boot.py index c56b30a3cb..7c43c68488 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -101,7 +101,7 @@ def get_bootinfo(): bootinfo.app_logo_url = get_app_logo() bootinfo.link_title_doctypes = get_link_title_doctypes() bootinfo.translated_doctypes = get_translated_doctypes() - bootinfo.subscription_expiry = add_subscription_expiry() + bootinfo.subscription_conf = add_subscription_conf() return bootinfo @@ -435,8 +435,8 @@ def load_currency_docs(bootinfo): bootinfo.docs += currency_docs -def add_subscription_expiry(): +def add_subscription_conf(): try: - return frappe.conf.subscription["expiry"] + return frappe.conf.subscription except Exception: return "" diff --git a/frappe/hooks.py b/frappe/hooks.py index 28b2c0dea1..aa6d6f00c7 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -220,7 +220,6 @@ scheduler_events = { "frappe.automation.doctype.auto_repeat.auto_repeat.set_auto_repeat_as_completed", "frappe.email.doctype.unhandled_email.unhandled_email.remove_old_unhandled_emails", "frappe.core.doctype.log_settings.log_settings.run_log_clean_up", - "frappe.utils.subscription.enable_manage_subscription", ], "daily_long": [ "frappe.integrations.doctype.dropbox_settings.dropbox_settings.take_backups_daily", diff --git a/frappe/patches.txt b/frappe/patches.txt index ec55c7fedd..b2e2f1392d 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -215,7 +215,6 @@ frappe.patches.v14_0.update_multistep_webforms execute:frappe.delete_doc('Page', 'background_jobs', ignore_missing=True, force=True) frappe.patches.v14_0.drop_unused_indexes frappe.patches.v15_0.drop_modified_index -frappe.patches.v14_0.add_manage_subscriptions_in_navbar_settings frappe.patches.v14_0.update_attachment_comment frappe.patches.v15_0.set_contact_full_name execute:frappe.delete_doc("Page", "activity", force=1) diff --git a/frappe/patches/v14_0/add_manage_subscriptions_in_navbar_settings.py b/frappe/patches/v14_0/add_manage_subscriptions_in_navbar_settings.py deleted file mode 100644 index 0c54eddc93..0000000000 --- a/frappe/patches/v14_0/add_manage_subscriptions_in_navbar_settings.py +++ /dev/null @@ -1,25 +0,0 @@ -import frappe - - -def execute(): - navbar_settings = frappe.get_single("Navbar Settings") - - if frappe.db.exists("Navbar Item", {"item_label": "Manage Subscriptions"}): - return - - for idx, row in enumerate(navbar_settings.settings_dropdown[2:], start=4): - row.idx = idx - - navbar_settings.append( - "settings_dropdown", - { - "item_label": "Manage Subscriptions", - "item_type": "Action", - "action": "frappe.ui.toolbar.redirectToUrl()", - "is_standard": 1, - "hidden": 1, - "idx": 3, - }, - ) - - navbar_settings.save() diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index 3383c6aaeb..6697c034bc 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -83,7 +83,6 @@ import "./frappe/ui/toolbar/search_utils.js"; import "./frappe/ui/toolbar/about.js"; import "./frappe/ui/toolbar/navbar.html"; import "./frappe/ui/toolbar/toolbar.js"; -import "./frappe/ui/toolbar/subscription.js"; // import "./frappe/ui/toolbar/notifications.js"; import "./frappe/views/communication.js"; import "./frappe/views/translation_manager.js"; diff --git a/frappe/public/js/frappe/ui/toolbar/subscription.js b/frappe/public/js/frappe/ui/toolbar/subscription.js deleted file mode 100644 index cde855f989..0000000000 --- a/frappe/public/js/frappe/ui/toolbar/subscription.js +++ /dev/null @@ -1,80 +0,0 @@ -$(document).on("startup", async () => { - if (!frappe.boot.setup_complete || !frappe.user.has_role("System Manager")) { - return; - } - - const expiry = frappe.boot.subscription_expiry; - - if (expiry) { - let diff_days = - frappe.datetime.get_day_diff(cstr(expiry), frappe.datetime.get_today()) - 1; - - let subscription_string = __( - `Your subscription will end in ${cstr(diff_days).bold()} ${ - diff_days > 1 ? "days" : "day" - }. After that your site will be suspended.` - ); - - let $bar = $(` -
-
-

${subscription_string}

-
- - -
-
-
- `); - - $("footer").append($bar); - - $bar.find(".dismiss-upgrade").on("click", () => { - $bar.remove(); - }); - - $bar.find(".button-renew").on("click", () => { - redirectToUrl(); - }); - } -}); - -function redirectToUrl() { - frappe.call({ - method: "frappe.utils.subscription.remote_login", - callback: (url) => { - if (url.message !== false) { - window.open(url.message, "_blank"); - } else { - frappe.msgprint({ - title: __("Message"), - indicator: "orange", - message: __("No active subscriptions found."), - }); - } - }, - }); -} - -$.extend(frappe.ui.toolbar, { - redirectToUrl() { - redirectToUrl(); - }, -}); diff --git a/frappe/utils/subscription.py b/frappe/utils/subscription.py deleted file mode 100644 index a1ade0e8b3..0000000000 --- a/frappe/utils/subscription.py +++ /dev/null @@ -1,35 +0,0 @@ -import json - -import requests - -import frappe - - -@frappe.whitelist() -def remote_login(): - try: - login_url = frappe.conf.subscription["login_url"] - if login_url: - resp = requests.post(login_url) - - if resp.status_code != 200: - return - - return json.loads(resp.text)["message"] - except Exception: - return False - - return False - - -def enable_manage_subscription(): - if not frappe.db.exists("Navbar Item", {"item_label": "Manage Subscriptions"}): - return - - navbar_item, hidden = frappe.db.get_value( - "Navbar Item", {"item_label": "Manage Subscriptions"}, ["name", "hidden"] - ) - if navbar_item and hidden: - doc = frappe.get_cached_doc("Navbar Item", navbar_item) - doc.hidden = False - doc.save() From 1cc51e6bb0bb43a0db11b3ba2005a6adc3ec97f5 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 14 Feb 2023 12:24:31 +0530 Subject: [PATCH 293/407] fix: Wait for user creation before creating contact (#20022) closes https://github.com/frappe/frappe/issues/19995 --- frappe/core/doctype/user/user.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index a7e5cf7669..e04e43051f 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -122,11 +122,20 @@ class User(Document): now = frappe.flags.in_test or frappe.flags.in_install self.send_password_notification(self.__new_password) frappe.enqueue( - "frappe.core.doctype.user.user.create_contact", user=self, ignore_mandatory=True, now=now + "frappe.core.doctype.user.user.create_contact", + user=self, + ignore_mandatory=True, + now=now, + enqueue_after_commit=True, ) if self.name not in STANDARD_USERS and not self.user_image: - frappe.enqueue("frappe.core.doctype.user.user.update_gravatar", name=self.name, now=now) + frappe.enqueue( + "frappe.core.doctype.user.user.update_gravatar", + name=self.name, + now=now, + enqueue_after_commit=True, + ) # Set user selected timezone if self.time_zone: From 1c00972940efe18d822f9f1788c9b760874d6258 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Tue, 14 Feb 2023 07:01:53 +0000 Subject: [PATCH 294/407] fix: eval client scripts with immediately invoked `Function()` constructor (#19882) --- .../public/js/frappe/form/script_manager.js | 4 +-- frappe/public/js/frappe/model/model.js | 25 ++++++++----------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/frappe/public/js/frappe/form/script_manager.js b/frappe/public/js/frappe/form/script_manager.js index a5511285a8..e634ef81e5 100644 --- a/frappe/public/js/frappe/form/script_manager.js +++ b/frappe/public/js/frappe/form/script_manager.js @@ -176,12 +176,12 @@ frappe.ui.form.ScriptManager = class ScriptManager { } if (client_script) { - eval(client_script); + new Function(client_script)(); } if (!this.frm.doctype_layout && doctype.__custom_js) { try { - eval(doctype.__custom_js); + new Function(doctype.__custom_js)(); } catch (e) { frappe.msgprint({ title: __("Error in Client Script"), diff --git a/frappe/public/js/frappe/model/model.js b/frappe/public/js/frappe/model/model.js index b835989c07..0fa2ef3621 100644 --- a/frappe/public/js/frappe/model/model.js +++ b/frappe/public/js/frappe/model/model.js @@ -274,21 +274,18 @@ $.extend(frappe.model, { init_doctype: function (doctype) { var meta = locals.DocType[doctype]; - if (meta.__list_js) { - eval(meta.__list_js); - } - if (meta.__custom_list_js) { - eval(meta.__custom_list_js); - } - if (meta.__calendar_js) { - eval(meta.__calendar_js); - } - if (meta.__map_js) { - eval(meta.__map_js); - } - if (meta.__tree_js) { - eval(meta.__tree_js); + for (const asset_key of [ + "__list_js", + "__custom_list_js", + "__calendar_js", + "__map_js", + "__tree_js", + ]) { + if (meta[asset_key]) { + new Function(meta[asset_key])(); + } } + if (meta.__templates) { $.extend(frappe.templates, meta.__templates); } From 69a08ccd439cdadce2f2728f155b62e6afb1dce6 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Tue, 14 Feb 2023 08:12:55 +0100 Subject: [PATCH 295/407] feat: actually redirect to login (#20018) --- frappe/public/js/frappe/desk.js | 58 +++------------------------------ 1 file changed, 4 insertions(+), 54 deletions(-) diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 43263d5632..4a81e8620b 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -430,62 +430,12 @@ frappe.Application = class Application { }); } handle_session_expired() { - if (!frappe.app.session_expired_dialog) { - var dialog = new frappe.ui.Dialog({ - title: __("Session Expired"), - keep_open: true, - fields: [ - { - fieldtype: "Password", - fieldname: "password", - label: __("Please Enter Your Password to Continue"), - }, - ], - onhide: () => { - if (!dialog.logged_in) { - frappe.app.redirect_to_login(); - } - }, - }); - dialog.get_field("password").disable_password_checks(); - dialog.set_primary_action(__("Login"), () => { - dialog.set_message(__("Authenticating...")); - frappe.call({ - method: "login", - args: { - usr: frappe.session.user, - pwd: dialog.get_values().password, - }, - callback: (r) => { - if (r.message === "Logged In") { - dialog.logged_in = true; - - // revert backdrop - $(".modal-backdrop").css({ - opacity: "", - "background-color": "#334143", - }); - } - dialog.hide(); - }, - statusCode: () => { - dialog.hide(); - }, - }); - }); - frappe.app.session_expired_dialog = dialog; - } - if (!frappe.app.session_expired_dialog.display) { - frappe.app.session_expired_dialog.show(); - // add backdrop - $(".modal-backdrop").css({ - opacity: 1, - "background-color": "#4B4C9D", - }); - } + frappe.app.redirect_to_login(); } redirect_to_login() { - window.location.href = "/"; + window.location.href = `/login?redirect-to=${encodeURIComponent( + window.location.pathname + window.location.search + )}`; } set_favicon() { var link = $('link[type="image/x-icon"]').remove().attr("href"); From 044eec35a61cecaf5c443f7d90a9d4072ff7a351 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Tue, 14 Feb 2023 14:00:42 +0530 Subject: [PATCH 296/407] fix: Do not filter columns like "_assign" & "_user_tags" --- frappe/model/db_query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py index c42c0bc00a..893d349d70 100644 --- a/frappe/model/db_query.py +++ b/frappe/model/db_query.py @@ -644,7 +644,7 @@ class DatabaseQuery: permitted_child_table_fields = get_permitted_fields( doctype=ch_doctype, parenttype=self.doctype ) - if column in permitted_child_table_fields: + if column in permitted_child_table_fields or column in optional_fields: continue else: self.remove_field(i) From efd82c464ed5bed81bc879e35a907e2fe56b3194 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Tue, 14 Feb 2023 15:57:11 +0530 Subject: [PATCH 297/407] fix: checkbox is getting squeezed if label is long --- .../print_format_builder_column_selector.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/printing/page/print_format_builder/print_format_builder_column_selector.html b/frappe/printing/page/print_format_builder/print_format_builder_column_selector.html index 495b837f48..adc87fff22 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder_column_selector.html +++ b/frappe/printing/page/print_format_builder/print_format_builder_column_selector.html @@ -17,10 +17,10 @@
From 013f023255f4d79b4149c9094d67632fe17cc7fd Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Tue, 14 Feb 2023 17:55:12 +0530 Subject: [PATCH 298/407] fix: restrict DocType layout permissions (#20028) Guest -> All. This doctype doesn't have anything that's useful for guest users. [skip ci] --- frappe/custom/doctype/doctype_layout/doctype_layout.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frappe/custom/doctype/doctype_layout/doctype_layout.json b/frappe/custom/doctype/doctype_layout/doctype_layout.json index 0b627f78ce..ffb5cdae31 100644 --- a/frappe/custom/doctype/doctype_layout/doctype_layout.json +++ b/frappe/custom/doctype/doctype_layout/doctype_layout.json @@ -43,7 +43,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2022-09-01 03:22:33.973058", + "modified": "2023-02-14 17:53:24.486171", "modified_by": "Administrator", "module": "Custom", "name": "DocType Layout", @@ -64,7 +64,7 @@ }, { "read": 1, - "role": "Guest" + "role": "All" } ], "route": "doctype-layout", From 0bd74bfa5cc334b07f3d94582627de7b4e02bc70 Mon Sep 17 00:00:00 2001 From: Samuel Danieli <23150094+scdanieli@users.noreply.github.com> Date: Tue, 14 Feb 2023 18:13:31 +0100 Subject: [PATCH 299/407] chore: do not copy data import status (#20034) status is used by other fields and may corrupt view if copied --- frappe/core/doctype/data_import/data_import.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/data_import/data_import.json b/frappe/core/doctype/data_import/data_import.json index 9e948dac8c..faa9a33bf1 100644 --- a/frappe/core/doctype/data_import/data_import.json +++ b/frappe/core/doctype/data_import/data_import.json @@ -94,6 +94,7 @@ "fieldtype": "Select", "hidden": 1, "label": "Status", + "no_copy": 1, "options": "Pending\nSuccess\nPartial Success\nError", "read_only": 1 }, @@ -170,7 +171,7 @@ ], "hide_toolbar": 1, "links": [], - "modified": "2022-02-01 20:08:37.624914", + "modified": "2022-02-14 10:08:37.624914", "modified_by": "Administrator", "module": "Core", "name": "Data Import", @@ -194,4 +195,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} From 3f528dac7568fcb089ca274bd83ce098f04ed9e0 Mon Sep 17 00:00:00 2001 From: barredterra <14891507+barredterra@users.noreply.github.com> Date: Wed, 15 Feb 2023 00:44:37 +0100 Subject: [PATCH 300/407] build(deps): bump ipython to 8.10.0 Minor security fix: https://ipython.readthedocs.io/en/stable/whatsnew/version8.html#ipython-8-10 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 965990e028..1328299533 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "git-url-parse~=1.2.2", "gunicorn~=20.1.0", "html5lib~=1.1", - "ipython~=8.4.0", + "ipython~=8.10.0", "ldap3~=2.9", "markdown2~=2.4.0", "MarkupSafe>=2.1.0,<3", From d8101cdf696dc4e4d1f1e0f383a59541b6d82d41 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Wed, 15 Feb 2023 07:11:50 +0100 Subject: [PATCH 301/407] ci: print any vulnerabilities found (#20044) [skip ci] --- .github/workflows/linters.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index eb775e01cd..d050ecb6bc 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -96,4 +96,4 @@ jobs: pip install pip-audit cd ${GITHUB_WORKSPACE} sed -i '/dropbox/d' pyproject.toml # Remove dropbox temporarily https://github.com/dropbox/dropbox-sdk-python/pull/456 - pip-audit . + pip-audit --desc on . From 99513db0623b63d12ee938cd8b769210a6337d3e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 15 Feb 2023 13:08:35 +0530 Subject: [PATCH 302/407] feat: fetch from with fields in Customize form (#20046) extends https://github.com/frappe/frappe/pull/13760/ --- frappe/core/doctype/doctype/doctype.js | 83 +----------------- .../doctype/customize_form/customize_form.js | 4 + frappe/public/js/frappe/doctype/index.js | 86 +++++++++++++++++++ 3 files changed, 91 insertions(+), 82 deletions(-) diff --git a/frappe/core/doctype/doctype/doctype.js b/frappe/core/doctype/doctype/doctype.js index e851a50674..d9c31d312e 100644 --- a/frappe/core/doctype/doctype/doctype.js +++ b/frappe/core/doctype/doctype/doctype.js @@ -104,88 +104,7 @@ frappe.ui.form.on("DocType", { frappe.ui.form.on("DocField", { form_render(frm, doctype, docname) { - // Render two select fields for Fetch From instead of Small Text for better UX - let field = frm.cur_grid.grid_form.fields_dict.fetch_from; - $(field.input_area).hide(); - - let $doctype_select = $(``); - let $wrapper = $('
'); - $wrapper.append($doctype_select, $field_select); - field.$input_wrapper.append($wrapper); - $doctype_select.wrap('
'); - $field_select.wrap('
'); - - let row = frappe.get_doc(doctype, docname); - let curr_value = { doctype: null, fieldname: null }; - if (row.fetch_from) { - let [doctype, fieldname] = row.fetch_from.split("."); - curr_value.doctype = doctype; - curr_value.fieldname = fieldname; - } - - let doctypes = frm.doc.fields - .filter((df) => df.fieldtype == "Link") - .filter((df) => df.options && df.fieldname != row.fieldname) - .sort((a, b) => a.options.localeCompare(b.options)) - .map((df) => ({ - label: `${df.options} (${df.fieldname})`, - value: df.fieldname, - })); - $doctype_select.add_options([ - { label: __("Select DocType"), value: "", selected: true }, - ...doctypes, - ]); - - $doctype_select.on("change", () => { - row.fetch_from = ""; - frm.dirty(); - update_fieldname_options(); - }); - - function update_fieldname_options() { - $field_select.find("option").remove(); - - let link_fieldname = $doctype_select.val(); - if (!link_fieldname) return; - let link_field = frm.doc.fields.find((df) => df.fieldname === link_fieldname); - let link_doctype = link_field.options; - frappe.model.with_doctype(link_doctype, () => { - let fields = frappe.meta - .get_docfields(link_doctype, null, { - fieldtype: ["not in", frappe.model.no_value_type], - }) - .sort((a, b) => a.label.localeCompare(b.label)) - .map((df) => ({ - label: `${df.label} (${df.fieldtype})`, - value: df.fieldname, - })); - $field_select.add_options([ - { - label: __("Select Field"), - value: "", - selected: true, - disabled: true, - }, - ...fields, - ]); - - if (curr_value.fieldname) { - $field_select.val(curr_value.fieldname); - } - }); - } - - $field_select.on("change", () => { - let fetch_from = `${$doctype_select.val()}.${$field_select.val()}`; - row.fetch_from = fetch_from; - frm.dirty(); - }); - - if (curr_value.doctype) { - $doctype_select.val(curr_value.doctype); - update_fieldname_options(); - } + frm.trigger("setup_fetch_from_fields", doctype, docname); }, fieldtype: function (frm) { diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index fed8505147..618186c0de 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -263,6 +263,10 @@ frappe.ui.form.on("Customize Form Field", { f.is_custom_field = true; frm.trigger("setup_default_views"); }, + + form_render(frm, doctype, docname) { + frm.trigger("setup_fetch_from_fields", doctype, docname); + }, }); // can't delete standard links diff --git a/frappe/public/js/frappe/doctype/index.js b/frappe/public/js/frappe/doctype/index.js index 1562d430fd..0dc5fd0a34 100644 --- a/frappe/public/js/frappe/doctype/index.js +++ b/frappe/public/js/frappe/doctype/index.js @@ -114,4 +114,90 @@ frappe.model.DocTypeController = class DocTypeController extends frappe.ui.form. this.frm.set_df_property("fields", "reqd", this.frm.doc.autoname !== "Prompt"); } + + setup_fetch_from_fields(doc, doctype, docname) { + let frm = this.frm; + // Render two select fields for Fetch From instead of Small Text for better UX + let field = frm.cur_grid.grid_form.fields_dict.fetch_from; + $(field.input_area).hide(); + + let $doctype_select = $(``); + let $wrapper = $('
'); + $wrapper.append($doctype_select, $field_select); + field.$input_wrapper.append($wrapper); + $doctype_select.wrap('
'); + $field_select.wrap('
'); + + let row = frappe.get_doc(doctype, docname); + let curr_value = { doctype: null, fieldname: null }; + if (row.fetch_from) { + let [doctype, fieldname] = row.fetch_from.split("."); + curr_value.doctype = doctype; + curr_value.fieldname = fieldname; + } + + let doctypes = frm.doc.fields + .filter((df) => df.fieldtype == "Link") + .filter((df) => df.options && df.fieldname != row.fieldname) + .sort((a, b) => a.options.localeCompare(b.options)) + .map((df) => ({ + label: `${df.options} (${df.fieldname})`, + value: df.fieldname, + })); + $doctype_select.add_options([ + { label: __("Select DocType"), value: "", selected: true }, + ...doctypes, + ]); + + $doctype_select.on("change", () => { + row.fetch_from = ""; + frm.dirty(); + update_fieldname_options(); + }); + + function update_fieldname_options() { + $field_select.find("option").remove(); + + let link_fieldname = $doctype_select.val(); + if (!link_fieldname) return; + let link_field = frm.doc.fields.find((df) => df.fieldname === link_fieldname); + let link_doctype = link_field.options; + frappe.model.with_doctype(link_doctype, () => { + let fields = frappe.meta + .get_docfields(link_doctype, null, { + fieldtype: ["not in", frappe.model.no_value_type], + }) + .sort((a, b) => a.label.localeCompare(b.label)) + .map((df) => ({ + label: `${df.label} (${df.fieldtype})`, + value: df.fieldname, + })); + $field_select.add_options([ + { + label: __("Select Field"), + value: "", + selected: true, + disabled: true, + }, + ...fields, + ]); + + if (curr_value.fieldname) { + $field_select.val(curr_value.fieldname); + } + }); + } + + $field_select.on("change", () => { + let fetch_from = `${$doctype_select.val()}.${$field_select.val()}`; + row.fetch_from = fetch_from; + frm.dirty(); + }); + + if (curr_value.doctype) { + $doctype_select.val(curr_value.doctype); + update_fieldname_options(); + } + } }; From 6d70b5e93440272b241c70b0bd6e68593be5536a Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 15 Feb 2023 14:52:08 +0530 Subject: [PATCH 303/407] fix(app): Move after_request hook inside finally block Also, rename after_request fn to sync_database to better match functionality --- frappe/app.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/frappe/app.py b/frappe/app.py index ff2b95ecb7..0d80d5dc7c 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -74,12 +74,15 @@ def application(request: Request): response = handle_exception(e) else: - rollback = after_request(rollback) + rollback = sync_database(rollback) finally: if request.method in UNSAFE_HTTP_METHODS and frappe.db and rollback: frappe.db.rollback() + for after_request_task in frappe.get_hooks("after_request"): + frappe.call(after_request_task) + frappe.rate_limiter.update() frappe.monitor.stop(response) frappe.recorder.dump() @@ -321,10 +324,7 @@ def handle_exception(e): return response -def after_request(rollback: bool) -> bool: - for after_request_task in frappe.get_hooks("after_request"): - frappe.call(after_request_task) - +def sync_database(rollback: bool) -> bool: # if HTTP method would change server state, commit if necessary if ( frappe.db @@ -338,9 +338,8 @@ def after_request(rollback: bool) -> bool: rollback = False # update session - if getattr(frappe.local, "session_obj", None): - updated_in_db = frappe.local.session_obj.update() - if updated_in_db: + if session := getattr(frappe.local, "session_obj", None): + if session.update(): frappe.db.commit() rollback = False From 3235b7a77ea0350d0915dee0f00afea96f690427 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 15 Feb 2023 14:56:11 +0530 Subject: [PATCH 304/407] fix: Notification JS relied on leaked locals (#20048) this breaks after https://github.com/frappe/frappe/pull/19882 [skip ci] --- .../doctype/notification/notification.js | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/frappe/email/doctype/notification/notification.js b/frappe/email/doctype/notification/notification.js index a149aacd57..eb3e2e8634 100644 --- a/frappe/email/doctype/notification/notification.js +++ b/frappe/email/doctype/notification/notification.js @@ -1,16 +1,6 @@ // Copyright (c) 2018, Frappe Technologies and contributors // For license information, please see license.txt -this.frm.add_fetch("sender", "email_id", "sender_email"); - -this.frm.fields_dict.sender.get_query = function () { - return { - filters: { - enable_outgoing: 1, - }, - }; -}; - frappe.notification = { setup_fieldname_select: function (frm) { // get the doctype to update fields @@ -156,6 +146,15 @@ frappe.ui.form.on("Notification", { refresh: function (frm) { frappe.notification.setup_fieldname_select(frm); frappe.notification.setup_example_message(frm); + + frm.add_fetch("sender", "email_id", "sender_email"); + frm.set_query("sender", () => { + return { + filters: { + enable_outgoing: 1, + }, + }; + }); frm.get_field("is_standard").toggle(frappe.boot.developer_mode); frm.trigger("event"); }, From 9f73b94a7459929ded224f231495ed716fa33378 Mon Sep 17 00:00:00 2001 From: Raffael Meyer <14891507+barredterra@users.noreply.github.com> Date: Wed, 15 Feb 2023 10:29:36 +0100 Subject: [PATCH 305/407] build(deps): bump cryptography to 39.0.1 (#20042) Security fix: https://cryptography.io/en/latest/changelog/#v39-0-1 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1328299533..95ef65c0a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dependencies = [ "cairocffi==1.2.0", "chardet~=4.0.0", "croniter~=1.3.5", - "cryptography~=38.0.3", + "cryptography~=39.0.1", "email-reply-parser~=0.5.12", "git-url-parse~=1.2.2", "gunicorn~=20.1.0", @@ -50,7 +50,7 @@ dependencies = [ "premailer~=3.8.0", "psutil~=5.9.1", "psycopg2-binary~=2.9.1", - "pyOpenSSL~=22.1.0", + "pyOpenSSL~=23.0.0", "pycryptodome~=3.10.1", "pydantic~=1.10.2", "pyotp~=2.6.0", From fe26c542b77697a2a227ec1559f6536a06ba2fc7 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Wed, 15 Feb 2023 15:30:02 +0530 Subject: [PATCH 306/407] refactor: Move before/after tasks as hooks Moved before/after tasks in Requests as hooks for: - monitor - rate_limiter - recorder Moved before/after tasks in Jobs as hooks for: - monitor - releasing document locks --- frappe/app.py | 10 ++-------- frappe/hooks.py | 17 +++++++++++++++++ frappe/utils/background_jobs.py | 10 +--------- frappe/utils/file_lock.py | 8 +++++++- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/frappe/app.py b/frappe/app.py index 0d80d5dc7c..01a5b5f218 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -35,15 +35,13 @@ _sites_path = os.environ.get("SITES_PATH", ".") @Request.application def application(request: Request): response = None + e = None try: rollback = True init_request(request) - frappe.recorder.record() - frappe.monitor.start() - frappe.rate_limiter.apply() frappe.api.validate_auth() if request.method == "OPTIONS": @@ -81,11 +79,7 @@ def application(request: Request): frappe.db.rollback() for after_request_task in frappe.get_hooks("after_request"): - frappe.call(after_request_task) - - frappe.rate_limiter.update() - frappe.monitor.stop(response) - frappe.recorder.dump() + frappe.call(after_request_task, response=response, request=request, exception=e) log_request(request, response) process_response(response) diff --git a/frappe/hooks.py b/frappe/hooks.py index aa6d6f00c7..317439c358 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -393,3 +393,20 @@ ignore_links_on_delete = [ "Integration Request", "Unhandled Email", ] + +# Request Hooks +before_request = [ + "frappe.recorder.record", + "frappe.monitor.start", + "frappe.rate_limiter.apply", +] +after_request = ["frappe.rate_limiter.update", "frappe.monitor.stop", "frappe.recorder.dump"] + +# Background Job Hooks +before_job = [ + "frappe.monitor.start", +] +after_job = [ + "frappe.monitor.stop", + "frappe.utils.file_lock.release_document_locks", +] diff --git a/frappe/utils/background_jobs.py b/frappe/utils/background_jobs.py index a106c3beba..fed822700c 100755 --- a/frappe/utils/background_jobs.py +++ b/frappe/utils/background_jobs.py @@ -168,10 +168,8 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, else: method_name = cstr(method.__name__) - frappe.monitor.start("job", method_name, kwargs) - for before_job_task in frappe.get_hooks("before_job"): - frappe.call(before_job_task, method=method_name, kwargs=kwargs) + frappe.call(before_job_task, method=method_name, kwargs=kwargs, transaction_type="job") try: retval = method(**kwargs) @@ -211,12 +209,6 @@ def execute_job(site, method, event, job_name, kwargs, user=None, is_async=True, for after_job_task in frappe.get_hooks("after_job"): frappe.call(after_job_task, method=method_name, kwargs=kwargs, result=retval) - # background job hygiene: release file locks if unreleased - # if this breaks something, move it to failed jobs alone - gavin@frappe.io - for doc in frappe.local.locked_documents: - doc.unlock() - - frappe.monitor.stop() if is_async: frappe.destroy() diff --git a/frappe/utils/file_lock.py b/frappe/utils/file_lock.py index 5be89c42f6..60d8baec5d 100644 --- a/frappe/utils/file_lock.py +++ b/frappe/utils/file_lock.py @@ -11,7 +11,7 @@ Use `frappe.utils.synchroniztion.filelock` for process synchroniztion. import os from time import time -from frappe import _ +import frappe from frappe.utils import get_site_path, touch_file LOCKS_DIR = "locks" @@ -62,3 +62,9 @@ def get_lock_path(name): name = name.lower() lock_path = get_site_path(LOCKS_DIR, name + ".lock") return lock_path + + +def release_document_locks(): + """Unlocks all documents that were locked by the current context.""" + for doc in frappe.local.locked_documents: + doc.unlock() From 39761d3d7e8a5723c39edf87a241714d3ada3421 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 15 Feb 2023 16:55:28 +0530 Subject: [PATCH 307/407] feat(Calendar): Add a new option `convertToUserTz` to address timezone inconsistencies --- frappe/public/js/frappe/views/calendar/calendar.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/views/calendar/calendar.js b/frappe/public/js/frappe/views/calendar/calendar.js index cba7886a93..4a22457dd7 100644 --- a/frappe/public/js/frappe/views/calendar/calendar.js +++ b/frappe/public/js/frappe/views/calendar/calendar.js @@ -120,6 +120,7 @@ frappe.views.Calendar = class Calendar { start: "start", end: "end", allDay: "all_day", + convertToUserTz: "convert_to_user_tz", }; this.color_map = { danger: "red", @@ -372,10 +373,13 @@ frappe.views.Calendar = class Calendar { }); if (!me.field_map.allDay) d.allDay = 1; + if (!me.field_map.convertToUserTz) d.convertToUserTz = 1; // convert to user tz - d.start = frappe.datetime.convert_to_user_tz(d.start); - d.end = frappe.datetime.convert_to_user_tz(d.end); + if (d.convertToUserTz) { + d.start = frappe.datetime.convert_to_user_tz(d.start); + d.end = frappe.datetime.convert_to_user_tz(d.end); + } // show event on single day if start or end date is invalid if (!frappe.datetime.validate(d.start) && d.end) { From cd4d601ed48000afee4802b8b48637f2aa57246f Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 15 Feb 2023 17:51:21 +0530 Subject: [PATCH 308/407] fix: traceback sanitizer got extra positional args --- frappe/tests/test_utils.py | 1 + frappe/utils/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py index 59df08dd91..ad3367862e 100644 --- a/frappe/tests/test_utils.py +++ b/frappe/tests/test_utils.py @@ -972,4 +972,5 @@ class TestTBSanitization(FrappeTestCase): traceback = frappe.get_traceback(with_context=True) self.assertNotIn("42", traceback) self.assertIn("********", traceback) + self.assertIn("password =", traceback) self.assertIn("safe_value", traceback) diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index d37e8c201f..5d1aed259a 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -346,7 +346,7 @@ def _get_traceback_sanitizer(): return Format( custom_var_printers=[ # redact variables - *[(variable_name, lambda: placeholder) for variable_name in blocklist], + *[(variable_name, lambda *a, **kw: placeholder) for variable_name in blocklist], # redact dictionary keys (["_secret", dict, lambda *a, **kw: False], dict_printer), ], From ce6e86b48427c62c482c0765f2837583affd8c16 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan <67804911+iamejaaz@users.noreply.github.com> Date: Thu, 16 Feb 2023 12:32:37 +0530 Subject: [PATCH 309/407] fix: unable to upload image in responsive mode (#19963) Co-authored-by: Shariq Ansari --- frappe/public/js/frappe/form/sidebar/user_image.js | 2 ++ frappe/public/js/frappe/ui/page.js | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frappe/public/js/frappe/form/sidebar/user_image.js b/frappe/public/js/frappe/form/sidebar/user_image.js index 9fbcc480fb..ae6167e184 100644 --- a/frappe/public/js/frappe/form/sidebar/user_image.js +++ b/frappe/public/js/frappe/form/sidebar/user_image.js @@ -73,6 +73,8 @@ frappe.ui.form.setup_user_image_event = function (frm) { field.make_input(); } field.$input.trigger("attach_doc_image"); + // close sidebar + frm.page.close_sidebar(); } else { /// on remove event for a sidebar image wrapper remove attach file. frm.attachments.remove_attachment_by_filename( diff --git a/frappe/public/js/frappe/ui/page.js b/frappe/public/js/frappe/ui/page.js index b0df2d60fe..18e9c9492c 100644 --- a/frappe/public/js/frappe/ui/page.js +++ b/frappe/public/js/frappe/ui/page.js @@ -188,14 +188,15 @@ frappe.ui.Page = class Page { } setup_overlay_sidebar() { + this.sidebar.find(".close-sidebar").remove(); let overlay_sidebar = this.sidebar.find(".overlay-sidebar").addClass("opened"); $('
').hide().appendTo(this.sidebar).fadeIn(); let scroll_container = $("html").css("overflow-y", "hidden"); - this.sidebar.find(".close-sidebar").on("click", (e) => close_sidebar(e)); - this.sidebar.on("click", "button:not(.dropdown-toggle)", (e) => close_sidebar(e)); + this.sidebar.find(".close-sidebar").on("click", (e) => this.close_sidebar(e)); + this.sidebar.on("click", "button:not(.dropdown-toggle)", (e) => this.close_sidebar(e)); - let close_sidebar = () => { + this.close_sidebar = () => { scroll_container.css("overflow-y", ""); this.sidebar.find("div.close-sidebar").fadeOut(() => { overlay_sidebar From 8b3bd5adc3704957a3d416f5ca4e1b0bc987f8dc Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 16 Feb 2023 14:19:19 +0530 Subject: [PATCH 310/407] fix: help-icon taking space if doc url is not set --- frappe/public/js/frappe/form/controls/base_input.js | 2 +- frappe/public/js/frappe/form/grid.js | 2 +- frappe/public/scss/common/controls.scss | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/frappe/public/js/frappe/form/controls/base_input.js b/frappe/public/js/frappe/form/controls/base_input.js index 50030fdc04..7831a9e9b6 100644 --- a/frappe/public/js/frappe/form/controls/base_input.js +++ b/frappe/public/js/frappe/form/controls/base_input.js @@ -17,7 +17,7 @@ frappe.ui.form.ControlInput = class ControlInput extends frappe.ui.form.Control
- +
diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index b767dac932..8771229884 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -63,7 +63,7 @@ export default class Grid { let template = `
- +

diff --git a/frappe/public/scss/common/controls.scss b/frappe/public/scss/common/controls.scss index 08debbbde9..9fe5f82a35 100644 --- a/frappe/public/scss/common/controls.scss +++ b/frappe/public/scss/common/controls.scss @@ -135,6 +135,9 @@ select.form-control { content: ' *'; color: var(--red-400); } + .help:empty { + display: none; + } .ql-editor:not(.read-mode) { background-color: var(--control-bg); } From 6bf326996e9906cfd1887d6dc215be44b04126e5 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 16 Feb 2023 14:57:27 +0530 Subject: [PATCH 311/407] fix: do not show help-icon in report view (datatable) --- frappe/public/scss/desk/frappe_datatable.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frappe/public/scss/desk/frappe_datatable.scss b/frappe/public/scss/desk/frappe_datatable.scss index cfec31b72e..1bf3dc5b41 100644 --- a/frappe/public/scss/desk/frappe_datatable.scss +++ b/frappe/public/scss/desk/frappe_datatable.scss @@ -36,6 +36,10 @@ top: 5px; right: 10px; } + + .help { + display: none; + } } .dt-header { @@ -67,6 +71,10 @@ .checkbox { margin: 7px 0 7px 8px; + + .label-area { + display: none; + } } [data-fieldtype="Color"] .control-input { From 2d416098c2868b3609cae3dce605c4eadb0ae92d Mon Sep 17 00:00:00 2001 From: Zhixuan Lai Date: Thu, 16 Feb 2023 04:45:40 -0800 Subject: [PATCH 312/407] fix: link validation fetch from virtual doc (#20055) Problem: document.save() throws "Object is not scriptable exception" when fetching values from virtual doc. Root cause: ```python # .... if frappe.get_meta(doctype).get("is_virtual"): values = frappe.get_doc(doctype, docname) <--- Document is not scriptable. .as_dict() # .... def set_fetch_from_value(self, doctype, df, values): fetch_from_fieldname = df.fetch_from.split(".")[-1] value = values[fetch_from_fieldname] <--- Tries to access value by key and throws "Object is not scriptable" exception ``` Solution: ```python if frappe.get_meta(doctype).get("is_virtual"): values = frappe.get_doc(doctype, docname).as_dict() <--- Makes the document scriptable. ``` --- frappe/model/base_document.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index e3694d1baf..93fddcf686 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -762,7 +762,7 @@ class BaseDocument: values.name = doctype if frappe.get_meta(doctype).get("is_virtual"): - values = frappe.get_doc(doctype, docname) + values = frappe.get_doc(doctype, docname).as_dict() if values: setattr(self, df.fieldname, values.name) From 80a49329831233a4472e0bd8d868b875c3a10d68 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 16 Feb 2023 18:42:24 +0530 Subject: [PATCH 313/407] fix: ask before changing restricted fieldnames --- .../doctype/customize_form/customize_form.js | 61 ++++++++++++++----- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index fed8505147..73981eaf28 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -310,22 +310,53 @@ frappe.ui.form.on("DocType State", { }, }); -frappe.customize_form.set_primary_action = function (frm) { - frm.page.set_primary_action(__("Update"), function () { - if (frm.doc.doc_type) { - return frm.call({ - doc: frm.doc, - freeze: true, - btn: frm.page.btn_primary, - method: "save_customization", - callback: function (r) { - if (!r.exc) { - frappe.customize_form.clear_locals_and_refresh(frm); - frm.script_manager.trigger("doc_type"); - } - }, - }); +frappe.customize_form.validate_fieldnames = async function (frm) { + for (let i = 0; i < frm.doc.fields.length; i++) { + let field = frm.doc.fields[i]; + + let fieldname = field.label && frappe.model.scrub(field.label).toLowerCase(); + if ( + field.label && + !field.fieldname && + in_list(frappe.model.restricted_fields, fieldname) + ) { + let message = __( + "For field {0} in row {1}, fieldname {2} is restricted it will be renamed as {2}1. Do you want to continue?", + [field.label, field.idx, fieldname] + ); + await pause_to_confirm(message); } + } + + function pause_to_confirm(message) { + return new Promise((resolve) => { + frappe.confirm(message, () => resolve()); + }); + } +}; + +frappe.customize_form.save_customization = function (frm) { + if (frm.doc.doc_type) { + return frm.call({ + doc: frm.doc, + freeze: true, + freeze_message: __("Updating Customization..."), + btn: frm.page.btn_primary, + method: "save_customization", + callback: function (r) { + if (!r.exc) { + frappe.customize_form.clear_locals_and_refresh(frm); + frm.script_manager.trigger("doc_type"); + } + }, + }); + } +}; + +frappe.customize_form.set_primary_action = function (frm) { + frm.page.set_primary_action(__("Update"), async () => { + await this.validate_fieldnames(frm); + this.save_customization(frm); }); }; From 908545241bde7fe0561ac8b196f9c1e440e46a31 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Thu, 16 Feb 2023 19:31:07 +0530 Subject: [PATCH 314/407] fix: enable update button if fieldname change is rejected --- frappe/custom/doctype/customize_form/customize_form.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index a0be0f3d63..d1ee27faba 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -334,7 +334,13 @@ frappe.customize_form.validate_fieldnames = async function (frm) { function pause_to_confirm(message) { return new Promise((resolve) => { - frappe.confirm(message, () => resolve()); + frappe.confirm( + message, + () => resolve(), + () => { + frm.page.btn_primary.prop("disabled", false); + } + ); }); } }; From 110204e2df335ad7e04884b709a7f290deb683fb Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Thu, 16 Feb 2023 20:05:36 +0530 Subject: [PATCH 315/407] feat: publish button in blog post form --- frappe/website/doctype/blog_post/blog_post.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frappe/website/doctype/blog_post/blog_post.js b/frappe/website/doctype/blog_post/blog_post.js index 0266587f2e..5f7268d074 100644 --- a/frappe/website/doctype/blog_post/blog_post.js +++ b/frappe/website/doctype/blog_post/blog_post.js @@ -7,6 +7,8 @@ frappe.ui.form.on("Blog Post", { frm.set_df_property("hide_cta", "hidden", !value); }); + frm.trigger("add_publish_button"); + generate_google_search_preview(frm); }, title: function (frm) { @@ -30,6 +32,12 @@ frappe.ui.form.on("Blog Post", { }); } }, + add_publish_button(frm) { + frm.add_custom_button(frm.doc.published ? __("Unpublish") : __("Publish"), () => { + frm.set_value("published", !frm.doc.published); + frm.save(); + }); + }, }); function generate_google_search_preview(frm) { From 5d0bd512e10bd3b81c241c0f19d851ec5f62aca1 Mon Sep 17 00:00:00 2001 From: Gavin D'souza Date: Fri, 17 Feb 2023 11:24:01 +0530 Subject: [PATCH 316/407] fix(after_hook): Don't pass exception object to hook * it can fetch most relevant details via response object * Exceptions not supported by Frappe's WSGI (unsupported HTTP methods) may not be accessible to the after_request hooks - but the lack of active response may be an indicator / and peeking in the request --- frappe/app.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frappe/app.py b/frappe/app.py index 01a5b5f218..6b16464d2c 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -35,7 +35,6 @@ _sites_path = os.environ.get("SITES_PATH", ".") @Request.application def application(request: Request): response = None - e = None try: rollback = True @@ -79,7 +78,7 @@ def application(request: Request): frappe.db.rollback() for after_request_task in frappe.get_hooks("after_request"): - frappe.call(after_request_task, response=response, request=request, exception=e) + frappe.call(after_request_task, response=response, request=request) log_request(request, response) process_response(response) From 24d76375382db510b756eedc268b1a73f1921aa7 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Fri, 17 Feb 2023 11:31:53 +0530 Subject: [PATCH 317/407] fix: hide published checkbox --- frappe/website/doctype/blog_post/blog_post.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frappe/website/doctype/blog_post/blog_post.json b/frappe/website/doctype/blog_post/blog_post.json index 8b5ab54ba8..d41b344464 100644 --- a/frappe/website/doctype/blog_post/blog_post.json +++ b/frappe/website/doctype/blog_post/blog_post.json @@ -53,6 +53,7 @@ "default": "0", "fieldname": "published", "fieldtype": "Check", + "hidden": 1, "label": "Published" }, { @@ -215,7 +216,7 @@ "is_published_field": "published", "links": [], "make_attachments_public": 1, - "modified": "2022-10-18 10:09:10.550734", + "modified": "2023-02-17 11:31:32.223524", "modified_by": "Administrator", "module": "Website", "name": "Blog Post", From 2af64893a31e3f6df2cfaeda7cf45208952dab72 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Thu, 16 Feb 2023 18:32:34 +0530 Subject: [PATCH 318/407] feat: allow number cards in workspaces --- frappe/desk/desktop.py | 28 +++++++-- .../desk/doctype/number_card/number_card.py | 4 +- frappe/desk/doctype/workspace/workspace.json | 17 ++++- .../doctype/workspace_number_card/__init__.py | 0 .../workspace_number_card.json | 40 ++++++++++++ .../workspace_number_card.py | 9 +++ .../js/frappe/views/workspace/blocks/index.js | 2 + .../views/workspace/blocks/number_card.js | 62 +++++++++++++++++++ .../js/frappe/views/workspace/workspace.js | 8 +++ .../js/frappe/widgets/number_card_widget.js | 4 +- .../public/js/frappe/widgets/widget_dialog.js | 2 +- 11 files changed, 164 insertions(+), 12 deletions(-) create mode 100644 frappe/desk/doctype/workspace_number_card/__init__.py create mode 100644 frappe/desk/doctype/workspace_number_card/workspace_number_card.json create mode 100644 frappe/desk/doctype/workspace_number_card/workspace_number_card.py create mode 100644 frappe/public/js/frappe/views/workspace/blocks/number_card.js diff --git a/frappe/desk/desktop.py b/frappe/desk/desktop.py index f2243c2e56..824e144272 100644 --- a/frappe/desk/desktop.py +++ b/frappe/desk/desktop.py @@ -157,14 +157,11 @@ class Workspace: return False def build_workspace(self): + self.number_cards = {"items": self.get_number_cards()} self.cards = {"items": self.get_links()} - self.charts = {"items": self.get_charts()} - self.shortcuts = {"items": self.get_shortcuts()} - self.onboardings = {"items": self.get_onboardings()} - self.quick_lists = {"items": self.get_quick_lists()} def _doctype_contains_a_record(self, name): @@ -204,6 +201,22 @@ class Workspace: return item + @handle_not_exist + def get_number_cards(self): + all_number_cards = [] + if frappe.has_permission("Number Card", throw=False): + number_cards = self.doc.number_cards + for number_card in number_cards: + if frappe.has_permission("Number Card", doc=number_card.number_card_name): + # Translate label + number_card.label = ( + _(number_card.label) if number_card.label else _(number_card.number_card_name) + ) + + all_number_cards.append(number_card) + + return all_number_cards + @handle_not_exist def get_links(self): cards = self.doc.get_link_groups() @@ -349,6 +362,7 @@ def get_desktop_page(page): workspace = Workspace(loads(page)) workspace.build_workspace() return { + "number_cards": workspace.number_cards, "charts": workspace.charts, "shortcuts": workspace.shortcuts, "cards": workspace.cards, @@ -476,6 +490,10 @@ def save_new_widget(doc, page, blocks, new_widgets): if loads(new_widgets): widgets = _dict(loads(new_widgets)) + if widgets.number_card: + doc.number_cards.extend( + new_widget(widgets.number_card, "Workspace Number Card", "number_cards") + ) if widgets.chart: doc.charts.extend(new_widget(widgets.chart, "Workspace Chart", "charts")) if widgets.shortcut: @@ -511,7 +529,7 @@ def save_new_widget(doc, page, blocks, new_widgets): def clean_up(original_page, blocks): page_widgets = {} - for wid in ["shortcut", "card", "chart", "quick_list"]: + for wid in ["number_card", "shortcut", "card", "chart", "quick_list"]: # get list of widget's name from blocks page_widgets[wid] = [x["data"][wid + "_name"] for x in loads(blocks) if x["type"] == wid] diff --git a/frappe/desk/doctype/number_card/number_card.py b/frappe/desk/doctype/number_card/number_card.py index d940448cb1..0783a4ab29 100644 --- a/frappe/desk/doctype/number_card/number_card.py +++ b/frappe/desk/doctype/number_card/number_card.py @@ -124,10 +124,10 @@ def get_result(doc, filters, to_date=None): ) ] - filters = frappe.parse_json(filters) - if not filters: filters = [] + elif isinstance(filters, str): + filters = frappe.parse_json(filters) if to_date: filters.append([doc.document_type, "creation", "<", to_date]) diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index edd5c32e99..af5f9c4184 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -21,6 +21,8 @@ "public", "is_hidden", "content", + "number_cards_tab", + "number_cards", "tab_break_2", "charts", "tab_break_15", @@ -181,11 +183,22 @@ "fieldname": "is_hidden", "fieldtype": "Check", "label": "Is Hidden" + }, + { + "fieldname": "number_cards_tab", + "fieldtype": "Tab Break", + "label": "Number Cards" + }, + { + "fieldname": "number_cards", + "fieldtype": "Table", + "label": "Number Cards", + "options": "Workspace Number Card" } ], "in_create": 1, "links": [], - "modified": "2023-01-07 19:37:39.512482", + "modified": "2023-02-15 01:16:56.035205", "modified_by": "Administrator", "module": "Desk", "name": "Workspace", @@ -208,4 +221,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} \ No newline at end of file +} diff --git a/frappe/desk/doctype/workspace_number_card/__init__.py b/frappe/desk/doctype/workspace_number_card/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/desk/doctype/workspace_number_card/workspace_number_card.json b/frappe/desk/doctype/workspace_number_card/workspace_number_card.json new file mode 100644 index 0000000000..f9e3865d74 --- /dev/null +++ b/frappe/desk/doctype/workspace_number_card/workspace_number_card.json @@ -0,0 +1,40 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2023-02-15 01:16:26.216201", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "number_card_name", + "label" + ], + "fields": [ + { + "fieldname": "number_card_name", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Number Card Name", + "options": "Number Card", + "reqd": 1 + }, + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2023-02-15 01:16:26.216201", + "modified_by": "Administrator", + "module": "Desk", + "name": "Workspace Number Card", + "owner": "Administrator", + "permissions": [], + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/frappe/desk/doctype/workspace_number_card/workspace_number_card.py b/frappe/desk/doctype/workspace_number_card/workspace_number_card.py new file mode 100644 index 0000000000..e972f3f525 --- /dev/null +++ b/frappe/desk/doctype/workspace_number_card/workspace_number_card.py @@ -0,0 +1,9 @@ +# Copyright (c) 2023, Frappe Technologies and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class WorkspaceNumberCard(Document): + pass diff --git a/frappe/public/js/frappe/views/workspace/blocks/index.js b/frappe/public/js/frappe/views/workspace/blocks/index.js index d15635fba9..afee5ab98a 100644 --- a/frappe/public/js/frappe/views/workspace/blocks/index.js +++ b/frappe/public/js/frappe/views/workspace/blocks/index.js @@ -1,6 +1,7 @@ // import blocks import Header from "./header"; import Paragraph from "./paragraph"; +import NumberCard from "./number_card"; import Card from "./card"; import Chart from "./chart"; import Shortcut from "./shortcut"; @@ -16,6 +17,7 @@ frappe.provide("frappe.workspace_block"); frappe.workspace_block.blocks = { header: Header, paragraph: Paragraph, + number_card: NumberCard, card: Card, chart: Chart, shortcut: Shortcut, diff --git a/frappe/public/js/frappe/views/workspace/blocks/number_card.js b/frappe/public/js/frappe/views/workspace/blocks/number_card.js new file mode 100644 index 0000000000..a952d7666b --- /dev/null +++ b/frappe/public/js/frappe/views/workspace/blocks/number_card.js @@ -0,0 +1,62 @@ +import Block from "./block.js"; +export default class NumberCard extends Block { + static get toolbox() { + return { + title: "Number Card", + icon: frappe.utils.icon("income", "sm"), + }; + } + + static get isReadOnlySupported() { + return true; + } + + constructor({ data, api, config, readOnly, block }) { + super({ data, api, config, readOnly, block }); + this.sections = {}; + this.col = this.data.col ? this.data.col : "4"; + this.allow_customization = !this.readOnly; + this.options = { + allow_sorting: this.allow_customization, + allow_create: this.allow_customization, + allow_delete: this.allow_customization, + allow_hiding: false, + allow_edit: true, + allow_resize: true, + }; + } + + render() { + this.wrapper = document.createElement("div"); + this.new("number_card"); + + if (this.data && this.data.number_card_name) { + let has_data = this.make("number_card", this.data.number_card_name); + if (!has_data) return; + } + + if (!this.readOnly) { + $(this.wrapper).find(".widget").addClass("number_card edit-mode"); + this.add_settings_button(); + this.add_new_block_button(); + } + + return this.wrapper; + } + + validate(savedData) { + if (!savedData.number_card_name) { + return false; + } + + return true; + } + + save() { + return { + number_card_name: this.wrapper.getAttribute("number_card_name"), + col: this.get_col(), + new: this.new_block_widget, + }; + } +} diff --git a/frappe/public/js/frappe/views/workspace/workspace.js b/frappe/public/js/frappe/views/workspace/workspace.js index 76f528dad7..fca6f35786 100644 --- a/frappe/public/js/frappe/views/workspace/workspace.js +++ b/frappe/public/js/frappe/views/workspace/workspace.js @@ -387,6 +387,7 @@ frappe.views.Workspace = class Workspace { this.editor.isReady.then(() => { this.editor.configuration.tools.chart.config.page_data = this.page_data; this.editor.configuration.tools.shortcut.config.page_data = this.page_data; + this.editor.configuration.tools.number_card.config.page_data = this.page_data; this.editor.configuration.tools.card.config.page_data = this.page_data; this.editor.configuration.tools.onboarding.config.page_data = this.page_data; this.editor.configuration.tools.quick_list.config.page_data = this.page_data; @@ -1334,9 +1335,16 @@ frappe.views.Workspace = class Workspace { page_data: this.page_data || [], }, }, + number_card: { + class: this.blocks["number_card"], + config: { + page_data: this.page_data || [], + }, + }, spacer: this.blocks["spacer"], HeaderSize: frappe.workspace_block.tunes["header_size"], }; + this.editor = new EditorJS({ data: { blocks: blocks || [], diff --git a/frappe/public/js/frappe/widgets/number_card_widget.js b/frappe/public/js/frappe/widgets/number_card_widget.js index 0874f825a6..03502cfd72 100644 --- a/frappe/public/js/frappe/widgets/number_card_widget.js +++ b/frappe/public/js/frappe/widgets/number_card_widget.js @@ -11,6 +11,7 @@ export default class NumberCardWidget extends Widget { get_config() { return { name: this.name, + number_card_name: this.number_card_name || this.name, label: this.label, color: this.color, hidden: this.hidden, @@ -31,7 +32,7 @@ export default class NumberCardWidget extends Widget { } make_card() { - frappe.model.with_doc("Number Card", this.name).then((card) => { + frappe.model.with_doc("Number Card", this.number_card_name || this.name).then((card) => { if (!card) { if (this.document_type) { frappe.run_serially([ @@ -144,7 +145,6 @@ export default class NumberCardWidget extends Widget { } render_card() { - this.prepare_actions(); this.set_title(); this.set_loading_state(); diff --git a/frappe/public/js/frappe/widgets/widget_dialog.js b/frappe/public/js/frappe/widgets/widget_dialog.js index 1696927cd8..56234071e3 100644 --- a/frappe/public/js/frappe/widgets/widget_dialog.js +++ b/frappe/public/js/frappe/widgets/widget_dialog.js @@ -643,7 +643,7 @@ class NumberCardDialog extends WidgetDialog { } data.stats_filter = this.filter_group && JSON.stringify(this.filter_group.get_filters()); data.document_type = this.document_type; - + data.label = data.label ? data.label : data.card; return data; } } From 649c211b9bce0fdae7399dc1c0a59dcf8cb4621e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 20 Feb 2023 10:13:49 +0530 Subject: [PATCH 319/407] fix(UX): show message when form is read only (#20077) [skip ci] --- frappe/public/js/frappe/form/form.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 001279f394..d1fe443f7a 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -406,7 +406,10 @@ frappe.ui.form.Form = class FrappeForm { // read only (workflow) this.read_only = frappe.workflow.is_read_only(this.doctype, this.docname); - if (this.read_only) this.set_read_only(true); + if (this.read_only) { + this.set_read_only(true); + frappe.show_alert(__("This form is not editable due to a Workflow.")); + } // check if doctype is already open if (!this.opendocs[this.docname]) { From 7afc46401b7c23620982745181ab659b636df1a3 Mon Sep 17 00:00:00 2001 From: Shariq Ansari <30859809+shariquerik@users.noreply.github.com> Date: Mon, 20 Feb 2023 11:21:24 +0530 Subject: [PATCH 320/407] chore: changed freeze message --- frappe/custom/doctype/customize_form/customize_form.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js index d1ee27faba..4ab693b415 100644 --- a/frappe/custom/doctype/customize_form/customize_form.js +++ b/frappe/custom/doctype/customize_form/customize_form.js @@ -350,7 +350,7 @@ frappe.customize_form.save_customization = function (frm) { return frm.call({ doc: frm.doc, freeze: true, - freeze_message: __("Updating Customization..."), + freeze_message: __("Saving Customization..."), btn: frm.page.btn_primary, method: "save_customization", callback: function (r) { From 89d63ea82b0fbd2d744aa890d1d49e0ad8804ffa Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 20 Feb 2023 12:18:37 +0530 Subject: [PATCH 321/407] fix: false positive attr check while applying permlevel (#20069) * fix: false positive attr check while applying permlevel * Revert "fix: false positive attr check while applying permlevel" This reverts commit 9114788590ce12be977df847c13b00e3bf72ac2a. * fix: ignore AttributeError while trying to pop low permlevel fields --------- Co-authored-by: Ankush Menat --- frappe/model/document.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/frappe/model/document.py b/frappe/model/document.py index 8a99676b60..7fcb9ac335 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -676,7 +676,11 @@ class Document(BaseDocument): for df in self.meta.fields: if df.permlevel and hasattr(self, df.fieldname) and df.permlevel not in has_access_to: - delattr(self, df.fieldname) + try: + delattr(self, df.fieldname) + except AttributeError: + # hasattr might return True for class attribute which can't be delattr-ed. + continue for table_field in self.meta.get_table_fields(): for df in frappe.get_meta(table_field.options).fields or []: From c94d3ccc16f4b674af2b0aabe446fd6aa1406aca Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 20 Feb 2023 12:51:24 +0530 Subject: [PATCH 322/407] fix: Hide perm level fields for Section, Column and Tab Breaks (#20084) --- frappe/core/doctype/docfield/docfield.json | 3 ++- .../doctype/customize_form_field/customize_form_field.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/frappe/core/doctype/docfield/docfield.json b/frappe/core/doctype/docfield/docfield.json index 0d8d7ea671..90b1c6cb77 100644 --- a/frappe/core/doctype/docfield/docfield.json +++ b/frappe/core/doctype/docfield/docfield.json @@ -304,6 +304,7 @@ }, { "default": "0", + "depends_on": "eval:!in_list(['Section Break', 'Column Break', 'Tab Break'], doc.fieldtype)", "fieldname": "permlevel", "fieldtype": "Int", "label": "Perm Level", @@ -555,7 +556,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-01-11 20:46:43.164926", + "modified": "2023-02-20 12:07:29.552523", "modified_by": "Administrator", "module": "Core", "name": "DocField", diff --git a/frappe/custom/doctype/customize_form_field/customize_form_field.json b/frappe/custom/doctype/customize_form_field/customize_form_field.json index ad0d600a0b..d8da44101b 100644 --- a/frappe/custom/doctype/customize_form_field/customize_form_field.json +++ b/frappe/custom/doctype/customize_form_field/customize_form_field.json @@ -212,6 +212,7 @@ }, { "default": "0", + "depends_on": "eval:!in_list(['Section Break', 'Column Break', 'Tab Break'], doc.fieldtype)", "fieldname": "permlevel", "fieldtype": "Int", "in_list_view": 1, @@ -467,7 +468,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-11-30 14:25:50.649449", + "modified": "2023-02-20 12:07:40.242470", "modified_by": "Administrator", "module": "Custom", "name": "Customize Form Field", From b55bbd0a8c3dde53565d6440a0d848f65aba056d Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 20 Feb 2023 13:07:32 +0530 Subject: [PATCH 323/407] fix(UX): Sort case-insensitive where it makes sense (#20088) --- frappe/core/page/permission_manager/permission_manager.py | 4 ++-- frappe/desk/doctype/tag/tag.py | 2 +- frappe/desk/search.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/core/page/permission_manager/permission_manager.py b/frappe/core/page/permission_manager/permission_manager.py index 45c1e44fa1..5ed3014778 100644 --- a/frappe/core/page/permission_manager/permission_manager.py +++ b/frappe/core/page/permission_manager/permission_manager.py @@ -62,8 +62,8 @@ def get_roles_and_doctypes(): roles_list = [{"label": _(d.get("name")), "value": d.get("name")} for d in roles] return { - "doctypes": sorted(doctypes_list, key=lambda d: d["label"]), - "roles": sorted(roles_list, key=lambda d: d["label"]), + "doctypes": sorted(doctypes_list, key=lambda d: d["label"].casefold()), + "roles": sorted(roles_list, key=lambda d: d["label"].casefold()), } diff --git a/frappe/desk/doctype/tag/tag.py b/frappe/desk/doctype/tag/tag.py index 84239fae6d..c5fe6407b7 100644 --- a/frappe/desk/doctype/tag/tag.py +++ b/frappe/desk/doctype/tag/tag.py @@ -59,7 +59,7 @@ def get_tags(doctype, txt): tag = frappe.get_list("Tag", filters=[["name", "like", f"%{txt}%"]]) tags = [t.name for t in tag] - return sorted(filter(lambda t: t and txt.lower() in t.lower(), list(set(tags)))) + return sorted(filter(lambda t: t and txt.casefold() in t.casefold(), list(set(tags)))) class DocTags: diff --git a/frappe/desk/search.py b/frappe/desk/search.py index 2af9b575be..ee63f67423 100644 --- a/frappe/desk/search.py +++ b/frappe/desk/search.py @@ -282,7 +282,7 @@ def scrub_custom_query(query, key, txt): def relevance_sorter(key, query, as_dict): value = _(key.name if as_dict else key[0]) - return (cstr(value).lower().startswith(query.lower()) is not True, value) + return (cstr(value).casefold().startswith(query.casefold()) is not True, value) def validate_and_sanitize_search_inputs(fn): From 68df7d621f8489ddb4ec6c4a6ebe4cae7dd07389 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Mon, 20 Feb 2023 13:13:35 +0530 Subject: [PATCH 324/407] docs: document_naming_settings field label [skip ci] --- .../document_naming_settings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.json b/frappe/core/doctype/document_naming_settings/document_naming_settings.json index 4c86b2ec1d..9a12f3f77e 100644 --- a/frappe/core/doctype/document_naming_settings/document_naming_settings.json +++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.json @@ -81,10 +81,10 @@ }, { "depends_on": "transaction_type", - "description": "Generate 3 preview of names generate by any valid series.", + "description": "Get a preview of generated names with a series.", "fieldname": "try_naming_series", "fieldtype": "Data", - "label": "Try a naming Series" + "label": "Try a Naming Series" }, { "fieldname": "transaction_type", @@ -111,7 +111,7 @@ "icon": "fa fa-sort-by-order", "issingle": 1, "links": [], - "modified": "2022-05-30 23:51:36.136535", + "modified": "2023-02-20 13:11:56.662100", "modified_by": "Administrator", "module": "Core", "name": "Document Naming Settings", @@ -130,4 +130,4 @@ "sort_field": "modified", "sort_order": "DESC", "states": [] -} +} \ No newline at end of file From 7f73906528123c0ead88b21c765270f817b25749 Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 20 Feb 2023 13:19:20 +0530 Subject: [PATCH 325/407] fix: switching from option store syntax to setup store syntax --- frappe/public/js/form_builder/store.js | 491 +++++++++++++------------ 1 file changed, 256 insertions(+), 235 deletions(-) diff --git a/frappe/public/js/form_builder/store.js b/frappe/public/js/form_builder/store.js index 246956dc94..c337e471d6 100644 --- a/frappe/public/js/form_builder/store.js +++ b/frappe/public/js/form_builder/store.js @@ -1,270 +1,291 @@ import { defineStore } from "pinia"; import { create_layout, scrub_field_names } from "./utils"; -import { nextTick } from "vue"; +import { computed, nextTick, ref } from "vue"; -export const useStore = defineStore("form-builder-store", { - state: () => ({ - doctype: "", - doc: null, - docfields: [], - custom_docfields: [], - layout: {}, - active_tab: "", - selected_field: null, - dirty: false, - read_only: false, - is_customize_form: false, - preview: false, - drag: false, - }), - getters: { - get_animation: () => { - return "cubic-bezier(0.34, 1.56, 0.64, 1)"; - }, - selected: (state) => { - return (name) => state.selected_field?.name == name; - }, - get_docfields: (state) => { - return state.is_customize_form ? state.custom_docfields : state.docfields; - }, - get_df: (state) => { - return (fieldtype, fieldname = "", label = "") => { - let docfield = state.is_customize_form ? "Customize Form Field" : "DocField"; - let df = frappe.model.get_new_doc(docfield); - df.name = frappe.utils.get_random(8); - df.fieldtype = fieldtype; - df.fieldname = fieldname; - df.label = label; - state.is_customize_form && (df.is_custom_field = 1); - return df; - }; - }, - has_standard_field: (state) => { - return (field) => { - if (!state.is_customize_form) return; - if (!field.df.is_custom_field) return true; +export const useStore = defineStore("form-builder-store", () => { + let doctype = ref(""); + let doc = ref(null); + let docfields = ref([]); + let custom_docfields = ref([]); + let layout = ref({}); + let active_tab = ref(""); + let selected_field = ref(null); + let dirty = ref(false); + let read_only = ref(false); + let is_customize_form = ref(false); + let preview = ref(false); + let drag = ref(false); + let get_animation = ref("cubic-bezier(0.34, 1.56, 0.64, 1)"); - let children = { - "Tab Break": "sections", - "Section Break": "columns", - "Column Break": "fields", - }[field.df.fieldtype]; + // Getters + let get_docfields = computed(() => { + return is_customize_form.value ? custom_docfields.value : docfields.value; + }); - if (!children) return false; + let current_tab = computed(() => { + return layout.value.tabs.find((tab) => tab.df.name == active_tab.value); + }); - return field[children].some((child) => { - if (!child.df.is_custom_field) return true; - return state.has_standard_field(child); - }); - }; - }, - current_tab: (state) => { - return state.layout.tabs.find((tab) => tab.df.name == state.active_tab); - }, - }, - actions: { - async fetch() { - await frappe.model.clear_doc("DocType", this.doctype); - await frappe.model.with_doctype(this.doctype); + // Actions + function selected(name) { + return selected_field.value?.name == name; + } - if (this.is_customize_form) { - await frappe.model.with_doc("Customize Form"); - let doc = frappe.get_doc("Customize Form"); - doc.doc_type = this.doctype; - let r = await frappe.call({ method: "fetch_to_customize", doc }); - this.doc = r.docs[0]; + function get_df(fieldtype, fieldname = "", label = "") { + let docfield = is_customize_form.value ? "Customize Form Field" : "DocField"; + let df = frappe.model.get_new_doc(docfield); + df.name = frappe.utils.get_random(8); + df.fieldtype = fieldtype; + df.fieldname = fieldname; + df.label = label; + is_customize_form.value && (df.is_custom_field = 1); + return df; + } + + function has_standard_field(field) { + if (!is_customize_form.value) return; + if (!field.df.is_custom_field) return true; + + let children = { + "Tab Break": "sections", + "Section Break": "columns", + "Column Break": "fields", + }[field.df.fieldtype]; + + if (!children) return false; + + return field[children].some((child) => { + if (!child.df.is_custom_field) return true; + return has_standard_field(child); + }); + } + + async function fetch() { + await frappe.model.clear_doc("DocType", doctype.value); + await frappe.model.with_doctype(doctype.value); + + if (is_customize_form.value) { + await frappe.model.with_doc("Customize Form"); + let doc = frappe.get_doc("Customize Form"); + doc.doc_type = doctype.value; + let r = await frappe.call({ method: "fetch_to_customize", doc }); + doc.value = r.docs[0]; + } else { + doc.value = await frappe.db.get_doc("DocType", doctype.value); + } + + if (!get_docfields.value.length) { + let docfield = is_customize_form.value ? "Customize Form Field" : "DocField"; + await frappe.model.with_doctype(docfield); + let df = frappe.get_meta(docfield).fields; + if (is_customize_form.value) { + custom_docfields.value = df; } else { - this.doc = await frappe.db.get_doc("DocType", this.doctype); + docfields.value = df; + } + } + + layout.value = get_layout(); + active_tab.value = layout.value.tabs[0].df.name; + selected_field.value = null; + + nextTick(() => { + dirty.value = false; + read_only.value = + !is_customize_form.value && !frappe.boot.developer_mode && !doc.value.custom; + preview.value = false; + }); + } + + function reset_changes() { + fetch(); + } + + function validate_fields(fields, is_table) { + fields = scrub_field_names(fields); + + let not_allowed_in_list_view = ["Attach Image", ...frappe.model.no_value_type]; + if (is_table) { + not_allowed_in_list_view = not_allowed_in_list_view.filter((f) => f != "Button"); + } + + function get_field_data(df) { + let fieldname = `${df.label} (${df.fieldname})`; + if (!df.label) { + fieldname = `${df.fieldname}`; + } + let fieldtype = `${df.fieldtype}`; + return [fieldname, fieldtype]; + } + + fields.forEach((df) => { + // check if fieldname already exist + let duplicate = fields.filter((f) => f.fieldname == df.fieldname); + if (duplicate.length > 1) { + frappe.throw(__("Fieldname {0} appears multiple times", get_field_data(df))); } - if (!this.get_docfields.length) { - let docfield = this.is_customize_form ? "Customize Form Field" : "DocField"; - await frappe.model.with_doctype(docfield); - let df = frappe.get_meta(docfield).fields; - if (this.is_customize_form) { - this.custom_docfields = df; - } else { - this.docfields = df; - } + // Link & Table fields should always have options set + if (in_list(["Link", ...frappe.model.table_fields], df.fieldtype) && !df.options) { + frappe.throw( + __("Options is required for field {0} of type {1}", get_field_data(df)) + ); } - this.layout = this.get_layout(); - this.active_tab = this.layout.tabs[0].df.name; - this.selected_field = null; - - nextTick(() => { - this.dirty = false; - this.read_only = - !this.is_customize_form && !frappe.boot.developer_mode && !this.doc.custom; - this.preview = false; - }); - }, - reset_changes() { - this.fetch(); - }, - validate_fields(fields, is_table) { - fields = scrub_field_names(fields); - - let not_allowed_in_list_view = ["Attach Image", ...frappe.model.no_value_type]; - if (is_table) { - not_allowed_in_list_view = not_allowed_in_list_view.filter((f) => f != "Button"); + // Do not allow if field is hidden & required but doesn't have default value + if (df.hidden && df.reqd && !df.default) { + frappe.throw( + __( + "{0} cannot be hidden and mandatory without any default value", + get_field_data(df) + ) + ); } - function get_field_data(df) { - let fieldname = `${df.label} (${df.fieldname})`; - if (!df.label) { - fieldname = `${df.fieldname}`; - } - let fieldtype = `${df.fieldtype}`; - return [fieldname, fieldtype]; + // In List View is not allowed for some fieldtypes + if (df.in_list_view && in_list(not_allowed_in_list_view, df.fieldtype)) { + frappe.throw( + __( + "'In List View' is not allowed for field {0} of type {1}", + get_field_data(df) + ) + ); } - fields.forEach((df) => { - // check if fieldname already exist - let duplicate = fields.filter((f) => f.fieldname == df.fieldname); - if (duplicate.length > 1) { - frappe.throw(__("Fieldname {0} appears multiple times", get_field_data(df))); - } + // In Global Search is not allowed for no_value_type fields + if (df.in_global_search && in_list(frappe.model.no_value_type, df.fieldtype)) { + frappe.throw( + __( + "'In Global Search' is not allowed for field {0} of type {1}", + get_field_data(df) + ) + ); + } + }); + } - // Link & Table fields should always have options set - if (in_list(["Link", ...frappe.model.table_fields], df.fieldtype) && !df.options) { - frappe.throw( - __("Options is required for field {0} of type {1}", get_field_data(df)) - ); - } + async function save_changes() { + if (!dirty.value) { + frappe.show_alert({ message: __("No changes to save"), indicator: "orange" }); + return; + } - // Do not allow if field is hidden & required but doesn't have default value - if (df.hidden && df.reqd && !df.default) { - frappe.throw( - __( - "{0} cannot be hidden and mandatory without any default value", - get_field_data(df) - ) - ); - } + frappe.dom.freeze(__("Saving...")); - // In List View is not allowed for some fieldtypes - if (df.in_list_view && in_list(not_allowed_in_list_view, df.fieldtype)) { - frappe.throw( - __( - "'In List View' is not allowed for field {0} of type {1}", - get_field_data(df) - ) - ); - } + try { + if (is_customize_form.value) { + let doc = frappe.get_doc("Customize Form"); + doc.doc_type = doctype.value; + doc.fields = get_updated_fields(); + validate_fields(doc.fields, doc.istable); + await frappe.call({ method: "save_customization", doc }); + } else { + doc.value.fields = get_updated_fields(); + validate_fields(doc.value.fields, doc.value.istable); + await frappe.call("frappe.client.save", { doc: doc.value }); + frappe.toast("Fields Table Updated"); + } + fetch(); + } catch (e) { + console.error(e); + } finally { + frappe.dom.unfreeze(); + } + } - // In Global Search is not allowed for no_value_type fields - if (df.in_global_search && in_list(frappe.model.no_value_type, df.fieldtype)) { - frappe.throw( - __( - "'In Global Search' is not allowed for field {0} of type {1}", - get_field_data(df) - ) - ); - } - }); - }, - async save_changes() { - if (!this.dirty) { - frappe.show_alert({ message: __("No changes to save"), indicator: "orange" }); - return; + function get_updated_fields() { + let fields = []; + let idx = 0; + + let layout_fields = JSON.parse(JSON.stringify(layout.value.tabs)); + + layout_fields.forEach((tab, i) => { + if ( + (i == 0 && is_df_updated(tab.df, get_df("Tab Break", "", __("Details")))) || + i > 0 + ) { + idx++; + tab.df.idx = idx; + fields.push(tab.df); } - frappe.dom.freeze(__("Saving...")); + tab.sections.forEach((section, j) => { + // data before section is added + let fields_copy = JSON.parse(JSON.stringify(fields)); + let old_idx = idx; + section.has_fields = false; - try { - if (this.is_customize_form) { - let doc = frappe.get_doc("Customize Form"); - doc.doc_type = this.doctype; - doc.fields = this.get_updated_fields(); - this.validate_fields(doc.fields, doc.istable); - await frappe.call({ method: "save_customization", doc }); - } else { - this.doc.fields = this.get_updated_fields(); - this.validate_fields(this.doc.fields, this.doc.istable); - await frappe.call({ - method: "frappe.desk.form.save.savedocs", - args: { doc: this.doc, action: "Save" }, - }); - } - this.fetch(); - } catch (e) { - console.error(e); - } finally { - frappe.dom.unfreeze(); - } - }, - get_updated_fields() { - let fields = []; - let idx = 0; - - let layout_fields = JSON.parse(JSON.stringify(this.layout.tabs)); - - layout_fields.forEach((tab, i) => { - if ( - (i == 0 && - this.is_df_updated(tab.df, this.get_df("Tab Break", "", __("Details")))) || - i > 0 - ) { + // do not consider first section if label is not set + if ((j == 0 && is_df_updated(section.df, get_df("Section Break"))) || j > 0) { idx++; - tab.df.idx = idx; - fields.push(tab.df); + section.df.idx = idx; + fields.push(section.df); } - tab.sections.forEach((section, j) => { - // data before section is added - let fields_copy = JSON.parse(JSON.stringify(fields)); - let old_idx = idx; - section.has_fields = false; - - // do not consider first section if label is not set + section.columns.forEach((column, k) => { + // do not consider first column if label is not set if ( - (j == 0 && this.is_df_updated(section.df, this.get_df("Section Break"))) || - j > 0 + (k == 0 && is_df_updated(column.df, get_df("Column Break"))) || + k > 0 || + column.fields.length == 0 ) { idx++; - section.df.idx = idx; - fields.push(section.df); + column.df.idx = idx; + fields.push(column.df); } - section.columns.forEach((column, k) => { - // do not consider first column if label is not set - if ( - (k == 0 && - this.is_df_updated(column.df, this.get_df("Column Break"))) || - k > 0 || - column.fields.length == 0 - ) { - idx++; - column.df.idx = idx; - fields.push(column.df); - } - - column.fields.forEach((field) => { - idx++; - field.df.idx = idx; - fields.push(field.df); - section.has_fields = true; - }); + column.fields.forEach((field) => { + idx++; + field.df.idx = idx; + fields.push(field.df); + section.has_fields = true; }); - - // restore data back to data before section is added. - if (!section.has_fields) { - fields = fields_copy || []; - idx = old_idx; - } }); - }); - return fields; - }, - is_df_updated(df, new_df) { - delete df.name; - delete new_df.name; - return JSON.stringify(df) != JSON.stringify(new_df); - }, - get_layout() { - return create_layout(this.doc.fields); - }, - }, + // restore data back to data before section is added. + if (!section.has_fields) { + fields = fields_copy || []; + idx = old_idx; + } + }); + }); + + return fields; + } + + function is_df_updated(df, new_df) { + delete df.name; + delete new_df.name; + return JSON.stringify(df) != JSON.stringify(new_df); + } + + function get_layout() { + return create_layout(doc.value.fields); + } + + return { + doctype, + doc, + layout, + active_tab, + selected_field, + dirty, + read_only, + is_customize_form, + preview, + drag, + get_animation, + selected, + get_docfields, + get_df, + has_standard_field, + current_tab, + fetch, + reset_changes, + validate_fields, + save_changes, + get_updated_fields, + is_df_updated, + get_layout, + }; }); From 28bba3c1885ce818d2c39fccd358a36d4f3b346f Mon Sep 17 00:00:00 2001 From: Shariq Ansari Date: Mon, 20 Feb 2023 13:20:41 +0530 Subject: [PATCH 326/407] fix: always use layout from store --- .../public/js/form_builder/components/Tabs.vue | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/frappe/public/js/form_builder/components/Tabs.vue b/frappe/public/js/form_builder/components/Tabs.vue index 625ca38745..3cf40865c4 100644 --- a/frappe/public/js/form_builder/components/Tabs.vue +++ b/frappe/public/js/form_builder/components/Tabs.vue @@ -9,9 +9,8 @@ import { ref, computed, nextTick } from "vue"; let store = useStore(); let dragged = ref(false); -let layout = computed(() => store.layout); -let has_tabs = computed(() => layout.value.tabs.length > 1); -store.active_tab = layout.value.tabs[0].df.name; +let has_tabs = computed(() => store.layout.tabs.length > 1); +store.active_tab = store.layout.tabs[0].df.name; function activate_tab(tab) { store.active_tab = tab.df.name; @@ -35,11 +34,11 @@ function drag_over(tab) { function add_new_tab() { let tab = { - df: store.get_df("Tab Break", "", "Tab " + (layout.value.tabs.length + 1)), + df: store.get_df("Tab Break", "", "Tab " + (store.layout.tabs.length + 1)), sections: [section_boilerplate()], }; - layout.value.tabs.push(tab); + store.layout.tabs.push(tab); activate_tab(tab); } @@ -77,7 +76,7 @@ function remove_tab() { } function delete_tab(with_children) { - let tabs = layout.value.tabs; + let tabs = store.layout.tabs; let index = tabs.indexOf(store.current_tab); if (!with_children) { @@ -109,11 +108,11 @@ function delete_tab(with_children) {