diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9c7ecf989e..93d43ddedf 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -2,7 +2,7 @@ name: Generate Semantic Release
on:
push:
branches:
- - version-13
+ - version-14-beta
jobs:
release:
name: Release
diff --git a/.releaserc b/.releaserc
index 530a6c0767..36193c4f0b 100644
--- a/.releaserc
+++ b/.releaserc
@@ -1,11 +1,10 @@
{
- "branches": ["version-13"],
+ "branches": [
+ {"name": "version-14-beta", "channel": "beta", "prerelease": true}
+ ],
"plugins": [
"@semantic-release/commit-analyzer", {
- "preset": "angular",
- "releaseRules": [
- {"breaking": true, "release": false}
- ]
+ "preset": "angular"
},
"@semantic-release/release-notes-generator",
[
diff --git a/frappe/__init__.py b/frappe/__init__.py
index 17a945c875..11acdc55f4 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -1258,8 +1258,10 @@ def get_module_path(module, *joins):
:param module: Module name.
:param *joins: Join additional path elements using `os.path.join`."""
- module = scrub(module)
- return get_pymodule_path(local.module_app[module] + "." + module, *joins)
+ from frappe.modules.utils import get_module_app
+
+ app = get_module_app(module)
+ return get_pymodule_path(app + "." + scrub(module), *joins)
def get_app_path(app_name, *joins):
@@ -1501,18 +1503,26 @@ def call(fn, *args, **kwargs):
def get_newargs(fn, kwargs):
+
+ # if function has any **kwargs parameter that capture arbitrary keyword arguments
+ # Ref: https://docs.python.org/3/library/inspect.html#inspect.Parameter.kind
+ varkw_exist = False
+
if hasattr(fn, "fnargs"):
fnargs = fn.fnargs
else:
signature = inspect.signature(fn)
fnargs = list(signature.parameters)
- varkw = "kwargs" in fnargs
- if varkw:
- fnargs.pop(-1)
+
+ for param_name, parameter in signature.parameters.items():
+ if parameter.kind == inspect.Parameter.VAR_KEYWORD:
+ varkw_exist = True
+ fnargs.remove(param_name)
+ break
newargs = {}
for a in kwargs:
- if (a in fnargs) or varkw:
+ if (a in fnargs) or varkw_exist:
newargs[a] = kwargs.get(a)
newargs.pop("ignore_permissions", None)
@@ -1808,18 +1818,21 @@ def get_value(*args, **kwargs):
return db.get_value(*args, **kwargs)
-def as_json(obj: Union[Dict, List], indent=1) -> str:
+def as_json(obj: Union[Dict, List], indent=1, separators=None) -> str:
from frappe.utils.response import json_handler
+ if separators is None:
+ separators = (",", ": ")
+
try:
return json.dumps(
- obj, indent=indent, sort_keys=True, default=json_handler, separators=(",", ": ")
+ obj, indent=indent, sort_keys=True, default=json_handler, separators=separators
)
except TypeError:
# this would break in case the keys are not all os "str" type - as defined in the JSON
# adding this to ensure keys are sorted (expected behaviour)
sorted_obj = dict(sorted(obj.items(), key=lambda kv: str(kv[0])))
- return json.dumps(sorted_obj, indent=indent, default=json_handler, separators=(",", ": "))
+ return json.dumps(sorted_obj, indent=indent, default=json_handler, separators=separators)
def are_emails_muted():
diff --git a/frappe/auth.py b/frappe/auth.py
index dc53c20f28..80141d1d6c 100644
--- a/frappe/auth.py
+++ b/frappe/auth.py
@@ -165,7 +165,7 @@ class LoginManager:
self.set_user_info()
def get_user_info(self):
- self.info = frappe.db.get_value(
+ self.info = frappe.get_cached_value(
"User", self.user, ["user_type", "first_name", "last_name", "user_image"], as_dict=1
)
@@ -412,10 +412,16 @@ def clear_cookies():
def validate_ip_address(user):
"""check if IP Address is valid"""
- user = (
- frappe.get_cached_doc("User", user) if not frappe.flags.in_test else frappe.get_doc("User", user)
+ from frappe.core.doctype.user.user import get_restricted_ip_list
+
+ # Only fetch required fields - for perf
+ user_fields = ["restrict_ip", "bypass_restrict_ip_check_if_2fa_enabled"]
+ user_info = (
+ frappe.get_cached_value("User", user, user_fields, as_dict=True)
+ if not frappe.flags.in_test
+ else frappe.db.get_value("User", user, user_fields, as_dict=True)
)
- ip_list = user.get_restricted_ip_list()
+ ip_list = get_restricted_ip_list(user_info)
if not ip_list:
return
@@ -430,7 +436,7 @@ def validate_ip_address(user):
# check if two factor auth is enabled
if system_settings.enable_two_factor_auth and not bypass_restrict_ip_check:
# check if bypass restrict ip is enabled for login user
- bypass_restrict_ip_check = user.bypass_restrict_ip_check_if_2fa_enabled
+ bypass_restrict_ip_check = user_info.bypass_restrict_ip_check_if_2fa_enabled
for ip in ip_list:
if frappe.local.request_ip.startswith(ip) or bypass_restrict_ip_check:
diff --git a/frappe/boot.py b/frappe/boot.py
index a23a7e6ac3..6cd86dc4fc 100644
--- a/frappe/boot.py
+++ b/frappe/boot.py
@@ -15,7 +15,7 @@ from frappe.geo.country_info import get_all
from frappe.model.base_document import get_controller
from frappe.query_builder import DocType
from frappe.query_builder.functions import Count
-from frappe.query_builder.terms import subqry
+from frappe.query_builder.terms import SubQuery
from frappe.social.doctype.energy_point_log.energy_point_log import get_energy_points
from frappe.social.doctype.energy_point_settings.energy_point_settings import (
is_energy_point_enabled,
@@ -211,7 +211,7 @@ def get_user_pages_or_reports(parent, cache=False):
if parent == "Report":
has_role[p.name].update({"ref_doctype": p.ref_doctype})
- no_of_roles = (
+ no_of_roles = SubQuery(
frappe.qb.from_(hasRole).select(Count("*")).where(hasRole.parent == parentTable.name)
)
@@ -221,7 +221,7 @@ def get_user_pages_or_reports(parent, cache=False):
pages_with_no_roles = (
frappe.qb.from_(parentTable)
.select(parentTable.name, parentTable.modified, *columns)
- .where(subqry(no_of_roles) == 0)
+ .where(no_of_roles == 0)
).run(as_dict=True)
for p in pages_with_no_roles:
@@ -327,7 +327,7 @@ def get_unseen_notes():
(note.notify_on_every_login == 1)
& (note.expire_notification_on > frappe.utils.now())
& (
- subqry(frappe.qb.from_(nsb).select(nsb.user).where(nsb.parent == note.name)).notin(
+ SubQuery(frappe.qb.from_(nsb).select(nsb.user).where(nsb.parent == note.name)).notin(
[frappe.session.user]
)
)
diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py
index 464bc35a1c..887037dca1 100755
--- a/frappe/core/doctype/communication/email.py
+++ b/frappe/core/doctype/communication/email.py
@@ -12,6 +12,7 @@ from frappe.utils import (
cint,
get_datetime,
get_formatted_email,
+ get_string_between,
list_to_str,
split_emails,
validate_email_address,
@@ -152,7 +153,7 @@ def _make(
"reference_doctype": doctype,
"reference_name": name,
"email_template": email_template,
- "message_id": get_message_id().strip(" <>"),
+ "message_id": get_string_between("<", get_message_id(), ">"),
"read_receipt": read_receipt,
"has_attachment": 1 if attachments else 0,
"communication_type": communication_type,
diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py
index 11f5ef8a69..0bcd972c68 100644
--- a/frappe/core/doctype/doctype/test_doctype.py
+++ b/frappe/core/doctype/doctype/test_doctype.py
@@ -1,10 +1,14 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
+import random
+import string
import unittest
from typing import Dict, List, Optional
+from unittest.mock import patch
import frappe
+from frappe.cache_manager import clear_doctype_cache
from frappe.core.doctype.doctype.doctype import (
CannotIndexedError,
DoctypeLinkError,
@@ -15,8 +19,8 @@ from frappe.core.doctype.doctype.doctype import (
WrongOptionsDoctypeLinkError,
validate_links_table_fieldnames,
)
-
-# test_records = frappe.get_test_records('DocType')
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+from frappe.desk.form.load import getdoc
class TestDocType(unittest.TestCase):
@@ -628,10 +632,55 @@ class TestDocType(unittest.TestCase):
self.assertEqual(test_json.test_json_field["hello"], "world")
+ @patch.dict(frappe.conf, {"developer_mode": 1})
+ def test_delete_doctype_with_customization(self):
+ from frappe.custom.doctype.property_setter.property_setter import make_property_setter
+
+ custom_field = "customfield"
+
+ doctype = new_doctype(custom=0).insert().name
+
+ # Create property setter and custom field
+ field = "some_fieldname"
+ make_property_setter(doctype, field, "default", "DELETETHIS", "Data")
+ create_custom_fields({doctype: [{"fieldname": custom_field, "fieldtype": "Data"}]})
+
+ # Create 1 record
+ original_doc = frappe.get_doc(doctype=doctype, custom_field_name="wat").insert()
+ self.assertEqual(original_doc.some_fieldname, "DELETETHIS")
+
+ # delete doctype
+ frappe.delete_doc("DocType", doctype)
+ clear_doctype_cache(doctype)
+
+ # "restore" doctype by inserting doctype with same schema again
+ new_doctype(doctype, custom=0).insert()
+
+ # Ensure basically same doctype getting "restored"
+ restored_doc = frappe.get_last_doc(doctype)
+ verify_fields = ["doctype", field, custom_field]
+ for f in verify_fields:
+ self.assertEqual(original_doc.get(f), restored_doc.get(f))
+
+ # Check form load of restored doctype
+ getdoc(doctype, restored_doc.name)
+
+ # ensure meta - property setter
+ self.assertEqual(frappe.get_meta(doctype).get_field(field).default, "DELETETHIS")
+ frappe.delete_doc("DocType", doctype)
+
def new_doctype(
- name, unique: bool = False, depends_on: str = "", fields: Optional[List[Dict]] = None, **kwargs
+ name: Optional[str] = None,
+ unique: bool = False,
+ depends_on: str = "",
+ fields: Optional[List[Dict]] = None,
+ **kwargs,
):
+ if not name:
+ # Test prefix is required to avoid coverage
+ name = "Test " + "".join(random.sample(string.ascii_lowercase, 10))
+
doc = frappe.get_doc(
{
"doctype": "DocType",
diff --git a/frappe/core/doctype/document_naming_settings/__init__.py b/frappe/core/doctype/document_naming_settings/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.js b/frappe/core/doctype/document_naming_settings/document_naming_settings.js
new file mode 100644
index 0000000000..2dc5fc4d58
--- /dev/null
+++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.js
@@ -0,0 +1,73 @@
+// Copyright (c) 2022, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("Document Naming Settings", {
+ refresh: function(frm) {
+ frm.trigger("setup_transaction_autocomplete");
+ frm.disable_save();
+ },
+
+ setup_transaction_autocomplete: function(frm) {
+ frappe.call({
+ method: "get_transactions_and_prefixes",
+ doc: frm.doc,
+ callback: function(r) {
+ frm.fields_dict.transaction_type.set_data(r.message.transactions);
+ frm.fields_dict.prefix.set_data(r.message.prefixes);
+ },
+ });
+ },
+
+ transaction_type: function(frm) {
+ frm.set_value("user_must_always_select", 0);
+ frappe.call({
+ method: "get_options",
+ doc: frm.doc,
+ callback: function(r) {
+ frm.set_value("naming_series_options", r.message);
+ if (r.message && r.message.split("\n")[0] == "")
+ frm.set_value("user_must_always_select", 1);
+ },
+ });
+ },
+
+ prefix: function(frm) {
+ frappe.call({
+ method: "get_current",
+ doc: frm.doc,
+ callback: function(r) {
+ frm.refresh_field("current_value");
+ },
+ });
+ },
+
+ update: function(frm) {
+ frappe.call({
+ method: "update_series",
+ doc: frm.doc,
+ freeze: true,
+ freeze_msg: __("Updating naming series options"),
+ callback: function(r) {
+ frm.trigger("setup_transaction_autocomplete");
+ frm.trigger("transaction_type");
+ },
+ });
+ },
+
+ try_naming_series(frm) {
+ frappe.call({
+ method: "preview_series",
+ doc: frm.doc,
+ callback: function(r) {
+ if (!r.exc) {
+ frm.set_value("series_preview", r.message);
+ } else {
+ frm.set_value(
+ "series_preview",
+ __("Failed to generate preview of series")
+ );
+ }
+ },
+ });
+ },
+});
diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.json b/frappe/core/doctype/document_naming_settings/document_naming_settings.json
new file mode 100644
index 0000000000..4c86b2ec1d
--- /dev/null
+++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.json
@@ -0,0 +1,133 @@
+{
+ "actions": [],
+ "creation": "2022-05-30 07:24:07.736646",
+ "description": "Configure various aspects of how document naming works like naming series, current counter.",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "naming_series_tab",
+ "setup_series",
+ "transaction_type",
+ "naming_series_options",
+ "user_must_always_select",
+ "update",
+ "column_break_9",
+ "try_naming_series",
+ "series_preview",
+ "help_html",
+ "update_series",
+ "prefix",
+ "current_value",
+ "update_series_start"
+ ],
+ "fields": [
+ {
+ "collapsible": 1,
+ "description": "Set Naming Series options on your transactions.",
+ "fieldname": "setup_series",
+ "fieldtype": "Section Break",
+ "label": "Setup Series for transactions"
+ },
+ {
+ "depends_on": "transaction_type",
+ "fieldname": "help_html",
+ "fieldtype": "HTML",
+ "label": "Help HTML",
+ "options": "
\n Edit list of Series in the box. Rules:\n
\n
Each Series Prefix on a new line.
\n
Allowed special characters are \"/\" and \"-\"
\n
\n Optionally, set the number of digits in the series using dot (.)\n followed by hashes (#). For example, \".####\" means that the series\n will have four digits. Default is five digits.\n
\n
\n You can also use variables in the series name by putting them\n between (.) dots\n \n Supported Variables:\n
\n
.YYYY. - Year in 4 digits
\n
.YY. - Year in 2 digits
\n
.MM. - Month
\n
.DD. - Day of month
\n
.WW. - Week of the year
\n
.FY. - Fiscal Year
\n
\n .{fieldname}. - fieldname on the document e.g.\n branch\n
\n
\n
\n
\n Examples:\n
\n
INV-
\n
INV-10-
\n
INVK-
\n
INV-.YYYY.-.{branch}.-.MM.-.####
\n
\n
\n \n"
+ },
+ {
+ "default": "0",
+ "depends_on": "transaction_type",
+ "description": "Check this if you want to force the user to select a series before saving. There will be no default if you check this.",
+ "fieldname": "user_must_always_select",
+ "fieldtype": "Check",
+ "label": "User must always select"
+ },
+ {
+ "depends_on": "transaction_type",
+ "fieldname": "update",
+ "fieldtype": "Button",
+ "label": "Update"
+ },
+ {
+ "collapsible": 1,
+ "description": "Change the starting / current sequence number of an existing series. \n\nWarning: Incorrectly updating counters can prevent documents from getting created. ",
+ "fieldname": "update_series",
+ "fieldtype": "Section Break",
+ "label": "Update Series Counter"
+ },
+ {
+ "fieldname": "prefix",
+ "fieldtype": "Autocomplete",
+ "label": "Prefix"
+ },
+ {
+ "description": "This is the number of the last created transaction with this prefix",
+ "fieldname": "current_value",
+ "fieldtype": "Int",
+ "label": "Current Value"
+ },
+ {
+ "fieldname": "update_series_start",
+ "fieldtype": "Button",
+ "label": "Update Series Number",
+ "options": "update_series_start"
+ },
+ {
+ "depends_on": "transaction_type",
+ "fieldname": "naming_series_options",
+ "fieldtype": "Text",
+ "label": "Series List for this Transaction"
+ },
+ {
+ "depends_on": "transaction_type",
+ "description": "Generate 3 preview of names generate by any valid series.",
+ "fieldname": "try_naming_series",
+ "fieldtype": "Data",
+ "label": "Try a naming Series"
+ },
+ {
+ "fieldname": "transaction_type",
+ "fieldtype": "Autocomplete",
+ "label": "Select Transaction"
+ },
+ {
+ "fieldname": "column_break_9",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "naming_series_tab",
+ "fieldtype": "Tab Break",
+ "label": "Naming Series"
+ },
+ {
+ "fieldname": "series_preview",
+ "fieldtype": "Text",
+ "label": "Preview of generated names",
+ "read_only": 1
+ }
+ ],
+ "hide_toolbar": 1,
+ "icon": "fa fa-sort-by-order",
+ "issingle": 1,
+ "links": [],
+ "modified": "2022-05-30 23:51:36.136535",
+ "modified_by": "Administrator",
+ "module": "Core",
+ "name": "Document Naming Settings",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/frappe/core/doctype/document_naming_settings/document_naming_settings.py b/frappe/core/doctype/document_naming_settings/document_naming_settings.py
new file mode 100644
index 0000000000..46def88b65
--- /dev/null
+++ b/frappe/core/doctype/document_naming_settings/document_naming_settings.py
@@ -0,0 +1,218 @@
+# Copyright (c) 2022, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+from typing import List, Set
+
+import frappe
+from frappe import _
+from frappe.core.doctype.doctype.doctype import validate_series
+from frappe.model.document import Document
+from frappe.model.naming import NamingSeries
+from frappe.permissions import get_doctypes_with_read
+from frappe.utils import cint
+
+
+class NamingSeriesNotSetError(frappe.ValidationError):
+ pass
+
+
+class DocumentNamingSettings(Document):
+ @frappe.whitelist()
+ def get_transactions_and_prefixes(self):
+
+ transactions = self._get_transactions()
+ prefixes = self._get_prefixes(transactions)
+
+ return {"transactions": transactions, "prefixes": prefixes}
+
+ def _get_transactions(self) -> List[str]:
+
+ readable_doctypes = set(get_doctypes_with_read())
+
+ standard = frappe.get_all("DocField", {"fieldname": "naming_series"}, "parent", pluck="parent")
+ custom = frappe.get_all("Custom Field", {"fieldname": "naming_series"}, "dt", pluck="dt")
+
+ return sorted(readable_doctypes.intersection(standard + custom))
+
+ def _get_prefixes(self, doctypes) -> List[str]:
+ """Get all prefixes for naming series.
+
+ - For all templates prefix is evaluated considering today's date
+ - All existing prefix in DB are shared as is.
+ """
+ series_templates = set()
+ for d in doctypes:
+ try:
+ options = frappe.get_meta(d).get_naming_series_options()
+ series_templates.update(options)
+ except frappe.DoesNotExistError:
+ frappe.msgprint(_("Unable to find DocType {0}").format(d))
+ continue
+
+ custom_templates = frappe.get_all(
+ "DocType",
+ fields=["autoname"],
+ filters={
+ "name": ("not in", doctypes),
+ "autoname": ("like", "%.#%"),
+ "module": ("not in", ["Core"]),
+ },
+ )
+ if custom_templates:
+ series_templates.update([d.autoname.rsplit(".", 1)[0] for d in custom_templates])
+
+ return self._evaluate_and_clean_templates(series_templates)
+
+ def _evaluate_and_clean_templates(self, series_templates: Set[str]) -> List[str]:
+ evalauted_prefix = set()
+
+ series = frappe.qb.DocType("Series")
+ prefixes_from_db = frappe.qb.from_(series).select(series.name).run(pluck=True)
+ evalauted_prefix.update(prefixes_from_db)
+
+ for series_template in series_templates:
+ prefix = NamingSeries(series_template).get_prefix()
+ if "{" in prefix:
+ # fieldnames can't be evalauted, rely on data in DB instead
+ continue
+ evalauted_prefix.add(prefix)
+
+ return sorted(evalauted_prefix)
+
+ def get_options_list(self, options: str) -> List[str]:
+ return [op.strip() for op in options.split("\n") if op.strip()]
+
+ @frappe.whitelist()
+ def update_series(self):
+ """update series list"""
+ self.validate_set_series()
+ self.check_duplicate()
+ self.set_series_options_in_meta(self.transaction_type, self.naming_series_options)
+
+ frappe.msgprint(
+ _("Series Updated for {}").format(self.transaction_type), alert=True, indicator="green"
+ )
+
+ def validate_set_series(self):
+ if self.transaction_type and not self.naming_series_options:
+ frappe.throw(_("Please set the series to be used."))
+
+ def set_series_options_in_meta(self, doctype: str, options: str) -> None:
+ options = self.get_options_list(options)
+
+ # validate names
+ for series in options:
+ self.validate_series_name(series)
+
+ if options and self.user_must_always_select:
+ options = [""] + options
+
+ default = options[0] if options else ""
+
+ option_string = "\n".join(options)
+
+ self.update_naming_series_property_setter(doctype, "options", option_string)
+ self.update_naming_series_property_setter(doctype, "default", default)
+
+ self.naming_series_options = option_string
+
+ frappe.clear_cache(doctype=doctype)
+
+ def update_naming_series_property_setter(self, doctype, property, value):
+ from frappe.custom.doctype.property_setter.property_setter import make_property_setter
+
+ make_property_setter(doctype, "naming_series", property, value, "Text")
+
+ def check_duplicate(self):
+ def stripped_series(s: str) -> str:
+ return s.strip().rstrip("#")
+
+ standard = frappe.get_all("DocField", {"fieldname": "naming_series"}, "parent", pluck="parent")
+ custom = frappe.get_all("Custom Field", {"fieldname": "naming_series"}, "dt", pluck="dt")
+
+ all_doctypes_with_naming_series = set(standard + custom)
+ all_doctypes_with_naming_series.remove(self.transaction_type)
+
+ existing_series = {}
+ for doctype in all_doctypes_with_naming_series:
+ for series in frappe.get_meta(doctype).get_naming_series_options():
+ existing_series[stripped_series(series)] = doctype
+
+ dt = frappe.get_doc("DocType", self.transaction_type)
+
+ options = self.get_options_list(self.naming_series_options)
+ for series in options:
+ if stripped_series(series) in existing_series:
+ frappe.throw(_("Series {0} already used in {1}").format(series, existing_series[series]))
+ validate_series(dt, series)
+
+ def validate_series_name(self, series):
+ NamingSeries(series).validate()
+
+ @frappe.whitelist()
+ def get_options(self, doctype=None):
+ doctype = doctype or self.transaction_type
+ if not doctype:
+ return
+
+ if frappe.get_meta(doctype or self.transaction_type).get_field("naming_series"):
+ return frappe.get_meta(doctype or self.transaction_type).get_field("naming_series").options
+
+ @frappe.whitelist()
+ def get_current(self):
+ """get series current"""
+ if self.prefix:
+ self.current_value = NamingSeries(self.prefix).get_current_value()
+ return self.current_value
+
+ @frappe.whitelist()
+ def update_series_start(self):
+ frappe.only_for("System Manager")
+
+ if not self.prefix:
+ frappe.throw(_("Please select prefix first"))
+
+ naming_series = NamingSeries(self.prefix)
+ previous_value = naming_series.get_current_value()
+ naming_series.update_counter(self.current_value)
+
+ self.create_version_log_for_change(
+ naming_series.get_prefix(), previous_value, self.current_value
+ )
+
+ frappe.msgprint(
+ _("Series counter for {} updated to {} successfully").format(self.prefix, self.current_value),
+ alert=True,
+ indicator="green",
+ )
+
+ def create_version_log_for_change(self, series, old, new):
+ version = frappe.new_doc("Version")
+ version.ref_doctype = "Series"
+ version.docname = series
+ version.data = frappe.as_json({"changed": [["current", old, new]]})
+ version.flags.ignore_links = True # series is not a "real" doctype
+ version.flags.ignore_permissions = True
+ version.insert()
+
+ @frappe.whitelist()
+ def preview_series(self) -> str:
+ """Preview what the naming series will generate."""
+
+ series = self.try_naming_series
+ if not series:
+ return ""
+ try:
+ doc = self._fetch_last_doc_if_available()
+ return "\n".join(NamingSeries(series).get_preview(doc=doc))
+ except Exception as e:
+ if frappe.message_log:
+ frappe.message_log.pop()
+ return _("Failed to generate names from the series") + f"\n{str(e)}"
+
+ def _fetch_last_doc_if_available(self):
+ """Fetch last doc for evaluating naming series with fields."""
+ try:
+ return frappe.get_last_doc(self.transaction_type)
+ except Exception:
+ return None
diff --git a/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py b/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py
new file mode 100644
index 0000000000..98ce9e738b
--- /dev/null
+++ b/frappe/core/doctype/document_naming_settings/test_document_naming_settings.py
@@ -0,0 +1,65 @@
+# Copyright (c) 2022, Frappe Technologies and Contributors
+# See license.txt
+
+import frappe
+from frappe.core.doctype.document_naming_settings.document_naming_settings import (
+ DocumentNamingSettings,
+)
+from frappe.model.naming import NamingSeries, get_default_naming_series
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import cint
+
+
+class TestNamingSeries(FrappeTestCase):
+ def setUp(self):
+ self.dns: DocumentNamingSettings = frappe.get_doc("Document Naming Settings")
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ def get_valid_serieses(self):
+ VALID_SERIES = ["SINV-", "SI-.{field}.", "SI-#.###", ""]
+ exisiting_series = self.dns.get_transactions_and_prefixes()["prefixes"]
+ return VALID_SERIES + exisiting_series
+
+ def test_naming_preview(self):
+ self.dns.transaction_type = "Webhook"
+
+ self.dns.try_naming_series = "AXBZ.####"
+ serieses = self.dns.preview_series().split("\n")
+ self.assertEqual(["AXBZ0001", "AXBZ0002", "AXBZ0003"], serieses)
+
+ self.dns.try_naming_series = "AXBZ-.{currency}.-"
+ serieses = self.dns.preview_series().split("\n")
+
+ def test_get_transactions(self):
+
+ naming_info = self.dns.get_transactions_and_prefixes()
+ self.assertIn("Webhook", naming_info["transactions"])
+
+ existing_naming_series = frappe.get_meta("Webhook").get_field("naming_series").options
+
+ for series in existing_naming_series.split("\n"):
+ self.assertIn(NamingSeries(series).get_prefix(), naming_info["prefixes"])
+
+ def test_default_naming_series(self):
+ self.assertIn("HOOK", get_default_naming_series("Webhook"))
+ self.assertIsNone(get_default_naming_series("DocType"))
+
+ def test_updates_naming_options(self):
+ self.dns.transaction_type = "Webhook"
+ test_series = "KOOHBEW.###"
+ self.dns.naming_series_options = self.dns.get_options() + "\n" + test_series
+ self.dns.update_series()
+ self.assertIn(test_series, frappe.get_meta("Webhook").get_naming_series_options())
+
+ def test_update_series_counter(self):
+ for series in self.get_valid_serieses():
+ if not series:
+ continue
+ self.dns.prefix = series
+ current_count = cint(self.dns.get_current())
+ new_count = self.dns.current_value = current_count + 1
+ self.dns.update_series_start()
+
+ self.assertEqual(self.dns.get_current(), new_count, f"Incorrect update for {series}")
diff --git a/frappe/core/doctype/report/report.py b/frappe/core/doctype/report/report.py
index 9e6cc73f11..efb45f41c8 100644
--- a/frappe/core/doctype/report/report.py
+++ b/frappe/core/doctype/report/report.py
@@ -89,7 +89,9 @@ class Report(Document):
]
custom_roles = get_custom_allowed_roles("report", self.name)
- allowed.extend(custom_roles)
+
+ if custom_roles:
+ allowed = custom_roles
if not allowed:
return True
diff --git a/frappe/core/doctype/report/test_report.py b/frappe/core/doctype/report/test_report.py
index 7b17a5a8d5..bbae616e93 100644
--- a/frappe/core/doctype/report/test_report.py
+++ b/frappe/core/doctype/report/test_report.py
@@ -186,6 +186,38 @@ class TestReport(FrappeTestCase):
self.assertNotEqual(report.is_permitted(), True)
frappe.set_user("Administrator")
+ def test_report_custom_permissions(self):
+ frappe.set_user("test@example.com")
+ frappe.db.delete("Custom Role", {"report": "Test Custom Role Report"})
+ frappe.db.commit() # nosemgrep
+ if not frappe.db.exists("Report", "Test Custom Role Report"):
+ report = frappe.get_doc(
+ {
+ "doctype": "Report",
+ "ref_doctype": "User",
+ "report_name": "Test Custom Role Report",
+ "report_type": "Query Report",
+ "is_standard": "No",
+ "roles": [{"role": "_Test Role"}, {"role": "System Manager"}],
+ }
+ ).insert(ignore_permissions=True)
+ else:
+ report = frappe.get_doc("Report", "Test Custom Role Report")
+
+ self.assertEqual(report.is_permitted(), True)
+
+ frappe.get_doc(
+ {
+ "doctype": "Custom Role",
+ "report": "Test Custom Role Report",
+ "roles": [{"role": "_Test Role 2"}],
+ "ref_doctype": "User",
+ }
+ ).insert(ignore_permissions=True)
+
+ self.assertNotEqual(report.is_permitted(), True)
+ frappe.set_user("Administrator")
+
# test for the `_format` method if report data doesn't have sort_by parameter
def test_format_method(self):
if frappe.db.exists("Report", "User Activity Report Without Sort"):
diff --git a/frappe/core/doctype/user/user.js b/frappe/core/doctype/user/user.js
index 77c199cdd4..6bbab0fdb3 100644
--- a/frappe/core/doctype/user/user.js
+++ b/frappe/core/doctype/user/user.js
@@ -194,14 +194,14 @@ frappe.ui.form.on('User', {
}
}
}
- if (frm.doc.user_emails){
- var found =0;
- for (var i = 0;i")],
"seen_status": {2: "UNSEEN"},
"uid_list": [2],
}
diff --git a/frappe/email/doctype/email_queue/email_queue.py b/frappe/email/doctype/email_queue/email_queue.py
index db2ca9e32b..662ba1b2ed 100644
--- a/frappe/email/doctype/email_queue/email_queue.py
+++ b/frappe/email/doctype/email_queue/email_queue.py
@@ -19,7 +19,15 @@ from frappe.email.email_body import add_attachment, get_email, get_formatted_htm
from frappe.email.queue import get_unsubcribed_url, get_unsubscribe_message
from frappe.model.document import Document
from frappe.query_builder.utils import DocType
-from frappe.utils import add_days, cint, cstr, get_hook_method, nowdate, split_emails
+from frappe.utils import (
+ add_days,
+ cint,
+ cstr,
+ get_hook_method,
+ get_string_between,
+ nowdate,
+ split_emails,
+)
MAX_RETRY_COUNT = 3
@@ -635,7 +643,7 @@ class QueueBuilder:
d = {
"priority": self.send_priority,
"attachments": json.dumps(self.get_attachments()),
- "message_id": mail.msg_root["Message-Id"].strip(" <>"),
+ "message_id": get_string_between("<", mail.msg_root["Message-Id"], ">"),
"message": mail_to_string,
"sender": self.sender,
"reference_doctype": self.reference_doctype,
diff --git a/frappe/email/doctype/newsletter/test_newsletter.py b/frappe/email/doctype/newsletter/test_newsletter.py
index 9ec61194ef..0cf388564f 100644
--- a/frappe/email/doctype/newsletter/test_newsletter.py
+++ b/frappe/email/doctype/newsletter/test_newsletter.py
@@ -1,7 +1,6 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See LICENSE
-import unittest
from random import choice
from typing import Union
from unittest.mock import MagicMock, PropertyMock, patch
@@ -17,9 +16,9 @@ from frappe.email.doctype.newsletter.newsletter import (
send_scheduled_email,
)
from frappe.email.queue import flush
+from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, getdate
-test_dependencies = ["Email Group"]
emails = [
"test_subscriber1@example.com",
"test_subscriber2@example.com",
@@ -63,6 +62,10 @@ class TestNewsletterMixin:
for email in emails:
doctype = "Email Group Member"
email_filters = {"email": email, "email_group": "_Test Email Group"}
+
+ savepoint = "setup_email_group"
+ frappe.db.savepoint(savepoint)
+
try:
frappe.get_doc(
{
@@ -71,8 +74,11 @@ class TestNewsletterMixin:
}
).insert()
except Exception:
+ frappe.db.rollback(save_point=savepoint)
frappe.db.update(doctype, email_filters, "unsubscribed", 0)
+ frappe.db.release_savepoint(savepoint)
+
def send_newsletter(self, published=0, schedule_send=None) -> Union[str, None]:
frappe.db.delete("Email Queue")
frappe.db.delete("Email Queue Recipient")
@@ -127,7 +133,7 @@ class TestNewsletterMixin:
return newsletter
-class TestNewsletter(TestNewsletterMixin, unittest.TestCase):
+class TestNewsletter(TestNewsletterMixin, FrappeTestCase):
def test_send(self):
self.send_newsletter()
diff --git a/frappe/email/receive.py b/frappe/email/receive.py
index 80c413faa1..d39eaa5213 100644
--- a/frappe/email/receive.py
+++ b/frappe/email/receive.py
@@ -25,6 +25,7 @@ from frappe.utils import (
cstr,
extract_email_id,
get_datetime,
+ get_string_between,
markdown,
now,
parse_addr,
@@ -425,7 +426,9 @@ class Email:
self.set_content_and_type()
self.set_subject()
self.set_from()
- self.message_id = (self.mail.get("Message-ID") or "").strip(" <>")
+
+ message_id = self.mail.get("Message-ID") or ""
+ self.message_id = get_string_between("<", message_id, ">")
if self.mail["Date"]:
try:
@@ -441,7 +444,8 @@ class Email:
@property
def in_reply_to(self):
- return (self.mail.get("In-Reply-To") or "").strip(" <>")
+ in_reply_to = self.mail.get("In-Reply-To") or ""
+ return get_string_between("<", in_reply_to, ">")
def parse(self):
"""Walk and process multi-part email."""
diff --git a/frappe/geo/country_info.json b/frappe/geo/country_info.json
index 735dcddac3..94d1f3ed37 100644
--- a/frappe/geo/country_info.json
+++ b/frappe/geo/country_info.json
@@ -2412,7 +2412,7 @@
"Singapore": {
"code": "sg",
"currency": "SGD",
- "currency_fraction": "Sen",
+ "currency_fraction": "Cent",
"currency_fraction_units": 100,
"currency_name": "Singapore Dollar",
"currency_symbol": "$",
diff --git a/frappe/installer.py b/frappe/installer.py
index 5cd46e618d..c8373ff06f 100644
--- a/frappe/installer.py
+++ b/frappe/installer.py
@@ -473,13 +473,20 @@ def set_all_patches_as_completed(app):
def init_singles():
- singles = [single["name"] for single in frappe.get_all("DocType", filters={"issingle": True})]
+ singles = frappe.get_all("DocType", filters={"issingle": True}, pluck="name")
for single in singles:
- if not frappe.db.get_singles_dict(single):
+ if frappe.db.get_singles_dict(single):
+ continue
+
+ try:
doc = frappe.new_doc(single)
doc.flags.ignore_mandatory = True
doc.flags.ignore_validate = True
doc.save()
+ except ImportError:
+ # The doctype exists, but controller is deleted,
+ # no need to attempt to init such single, ref: #16917
+ continue
def make_conf(
diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py
index 6a7f7fbd54..996ce2d129 100644
--- a/frappe/model/db_query.py
+++ b/frappe/model/db_query.py
@@ -34,6 +34,7 @@ class DatabaseQuery(object):
def __init__(self, doctype, user=None):
self.doctype = doctype
self.tables = []
+ self.link_tables = []
self.conditions = []
self.or_conditions = []
self.fields = None
@@ -216,6 +217,10 @@ class DatabaseQuery(object):
parent_name = cast_name(f"{self.tables[0]}.name")
args.tables += f" {self.join} {child} on ({child}.parent = {parent_name})"
+ # left join link tables
+ for link in self.link_tables:
+ args.tables += f" {self.join} `tab{link.doctype}` on (`tab{link.doctype}`.`name` = {self.tables[0]}.`{link.fieldname}`)"
+
if self.grouped_or_conditions:
self.conditions.append(f"({' or '.join(self.grouped_or_conditions)})")
@@ -287,6 +292,29 @@ class DatabaseQuery(object):
# remove empty strings / nulls in fields
self.fields = [f for f in self.fields if f]
+ # convert child_table.fieldname to `tabChild DocType`.`fieldname`
+ for field in self.fields:
+ if "." in field and "tab" not in field:
+ original_field = field
+ alias = None
+ 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_doctype = linked_field.options
+ if linked_field.fieldtype == "Link":
+ self.link_tables.append(
+ frappe._dict(
+ doctype=linked_doctype, fieldname=linked_fieldname, table_name=f"`tab{linked_doctype}`"
+ )
+ )
+
+ field = field.replace(linked_fieldname, f"`tab{linked_doctype}`")
+ field = field.replace(fieldname, f"`{fieldname}`")
+ if alias:
+ field = f"{field} as {alias}"
+ self.fields[self.fields.index(original_field)] = field
+
for filter_name in ["filters", "or_filters"]:
filters = getattr(self, filter_name)
if isinstance(filters, str):
@@ -396,7 +424,9 @@ class DatabaseQuery(object):
table_name = table_name[13:]
if not table_name[0] == "`":
table_name = f"`{table_name}`"
- if table_name not in self.tables:
+ if table_name not in self.tables and table_name not in (
+ d.table_name for d in self.link_tables
+ ):
self.append_table(table_name)
def append_table(self, table_name):
@@ -418,7 +448,7 @@ class DatabaseQuery(object):
methods = ("count(", "avg(", "sum(", "extract(", "dayofyear(")
return field.lower().startswith(methods)
- if len(self.tables) > 1:
+ 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):
self.fields[idx] = f"{self.tables[0]}.{field}"
diff --git a/frappe/model/delete_doc.py b/frappe/model/delete_doc.py
index 7f09345a48..985cc53682 100644
--- a/frappe/model/delete_doc.py
+++ b/frappe/model/delete_doc.py
@@ -3,6 +3,7 @@
import os
import shutil
+from typing import List
import frappe
import frappe.defaults
@@ -89,10 +90,6 @@ def delete_doc(
update_flags(doc, flags, ignore_permissions)
check_permission_and_not_submitted(doc)
- frappe.db.delete("Custom Field", {"dt": name})
- frappe.db.delete("Client Script", {"dt": name})
- frappe.db.delete("Property Setter", {"doc_type": name})
- frappe.db.delete("Report", {"ref_doctype": name})
frappe.db.delete("Custom DocPerm", {"parent": name})
frappe.db.delete("__global_search", {"doctype": name})
@@ -193,39 +190,24 @@ def update_naming_series(doc):
revert_series_if_last(doc.meta.autoname, doc.name, doc)
-def delete_from_table(doctype, name, ignore_doctypes, doc):
+def delete_from_table(doctype: str, name: str, ignore_doctypes: List[str], doc):
if doctype != "DocType" and doctype == name:
frappe.db.delete("Singles", {"doctype": name})
else:
frappe.db.delete(doctype, {"name": name})
- # get child tables
if doc:
- tables = [d.options for d in doc.meta.get_table_fields()]
-
+ child_doctypes = [d.options for d in doc.meta.get_table_fields()]
else:
+ child_doctypes = frappe.get_all(
+ "DocField",
+ fields="options",
+ filters={"fieldtype": ["in", frappe.model.table_fields], "parent": doctype},
+ pluck="options",
+ )
- def get_table_fields(field_doctype):
- if field_doctype == "Custom Field":
- return []
-
- return [
- r[0]
- for r in frappe.get_all(
- field_doctype,
- fields="options",
- filters={"fieldtype": ["in", frappe.model.table_fields], "parent": doctype},
- as_list=1,
- )
- ]
-
- tables = get_table_fields("DocField")
- if not frappe.flags.in_install == "frappe":
- tables += get_table_fields("Custom Field")
-
- # delete from child tables
- for t in list(set(tables)):
- if t not in ignore_doctypes:
- frappe.db.delete(t, {"parenttype": doctype, "parent": name})
+ child_doctypes_to_delete = set(child_doctypes) - set(ignore_doctypes)
+ for child_doctype in child_doctypes_to_delete:
+ frappe.db.delete(child_doctype, {"parenttype": doctype, "parent": name})
def update_flags(doc, flags=None, ignore_permissions=False):
diff --git a/frappe/model/meta.py b/frappe/model/meta.py
index 4f7dc01ea4..65ab7d39c2 100644
--- a/frappe/model/meta.py
+++ b/frappe/model/meta.py
@@ -17,6 +17,7 @@ Example:
import json
import os
from datetime import datetime
+from typing import List
import click
@@ -341,6 +342,16 @@ class Meta(Document):
def get_workflow(self):
return get_workflow_name(self.name)
+ def get_naming_series_options(self) -> List[str]:
+ """Get list naming series options."""
+
+ field = self.get_field("naming_series")
+ if field:
+ options = field.options or ""
+
+ return options.split("\n")
+ return []
+
def add_custom_fields(self):
if not frappe.db.table_exists("Custom Field"):
return
diff --git a/frappe/model/naming.py b/frappe/model/naming.py
index bb93244a66..d63466e556 100644
--- a/frappe/model/naming.py
+++ b/frappe/model/naming.py
@@ -2,7 +2,7 @@
# License: MIT. See LICENSE
import re
-from typing import TYPE_CHECKING, Optional, Union
+from typing import TYPE_CHECKING, Callable, List, Optional, Union
import frappe
from frappe import _
@@ -11,6 +11,7 @@ from frappe.query_builder import DocType
from frappe.utils import cint, cstr, now_datetime
if TYPE_CHECKING:
+ from frappe.model.document import Document
from frappe.model.meta import Meta
@@ -18,6 +19,95 @@ if TYPE_CHECKING:
# whether `log_types` have autoincremented naming set for the site or not.
autoincremented_site_status_map = {}
+NAMING_SERIES_PATTERN = re.compile(r"^[\w\- \/.#{}]+$", re.UNICODE)
+
+
+class InvalidNamingSeriesError(frappe.ValidationError):
+ pass
+
+
+class NamingSeries:
+ __slots__ = ("series",)
+
+ def __init__(self, series: str):
+ self.series = series
+
+ # Add default number part if missing
+ if "#" not in self.series:
+ self.series += ".#####"
+
+ def validate(self):
+ if "." not in self.series:
+ frappe.throw(
+ _("Invalid naming series {}: dot (.) missing").format(frappe.bold(self.series)),
+ exc=InvalidNamingSeriesError,
+ )
+
+ if not NAMING_SERIES_PATTERN.match(self.series):
+ frappe.throw(
+ _(
+ 'Special Characters except "-", "#", ".", "/", "{" and "}" not allowed in naming series',
+ ),
+ exc=InvalidNamingSeriesError,
+ )
+
+ def generate_next_name(self, doc: "Document") -> str:
+ self.validate()
+ parts = self.series.split(".")
+ return parse_naming_series(parts, doc=doc)
+
+ def get_prefix(self) -> str:
+ """Naming series stores prefix to maintain a counter in DB. This prefix can be used to update counter or validations.
+
+ e.g. `SINV-.YY.-.####` has prefix of `SINV-22-` in database for year 2022.
+ """
+
+ prefix = None
+
+ def fake_counter_backend(partial_series, digits):
+ nonlocal prefix
+ prefix = partial_series
+ return "#" * digits
+
+ # This function evaluates all parts till we hit numerical parts and then
+ # sends prefix + digits to DB to find next number.
+ # Instead of reimplementing the whole parsing logic in multiple places we
+ # can just ask this function to give us the prefix.
+ parse_naming_series(self.series, number_generator=fake_counter_backend)
+
+ if prefix is None:
+ frappe.throw(_("Invalid Naming Series"))
+
+ return prefix
+
+ def get_preview(self, doc=None) -> List[str]:
+ """Generate preview of naming series without using DB counters"""
+ generated_names = []
+ for count in range(1, 4):
+
+ def fake_counter(_prefix, digits):
+ return str(count).zfill(digits)
+
+ generated_names.append(parse_naming_series(self.series, doc=doc, number_generator=fake_counter))
+ return generated_names
+
+ def update_counter(self, new_count: int) -> None:
+ """Warning: Incorrectly updating series can result in unusable transactions"""
+ Series = frappe.qb.DocType("Series")
+ prefix = self.get_prefix()
+
+ # Initialize if not present in DB
+ if frappe.db.get_value("Series", prefix, "name", order_by="name") is None:
+ frappe.qb.into(Series).insert(prefix, 0).columns("name", "current").run()
+
+ (
+ frappe.qb.update(Series).set(Series.current, cint(new_count)).where(Series.name == prefix)
+ ).run()
+
+ def get_current_value(self) -> int:
+ prefix = self.get_prefix()
+ return cint(frappe.db.get_value("Series", prefix, "current", order_by="name"))
+
def set_new_name(doc):
"""
@@ -175,24 +265,32 @@ def make_autoname(key="", doctype="", doc=""):
if key == "hash":
return frappe.generate_hash(doctype, 10)
- if "#" not in key:
- key = key + ".#####"
- elif "." not in key:
- error_message = _("Invalid naming series (. missing)")
- if doctype:
- error_message = _("Invalid naming series (. missing) for {0}").format(doctype)
-
- frappe.throw(error_message)
-
- parts = key.split(".")
- n = parse_naming_series(parts, doctype, doc)
- return n
+ series = NamingSeries(key)
+ return series.generate_next_name(doc)
-def parse_naming_series(parts, doctype="", doc=""):
- n = ""
+def parse_naming_series(
+ parts: Union[List[str], str],
+ doctype=None,
+ doc: Optional["Document"] = None,
+ number_generator: Optional[Callable[[str, int], str]] = None,
+) -> str:
+
+ """Parse the naming series and get next name.
+
+ args:
+ parts: naming series parts (split by `.`)
+ doc: document to use for series that have parts using fieldnames
+ number_generator: Use different counter backend other than `tabSeries`. Primarily used for testing.
+ """
+
+ name = ""
if isinstance(parts, str):
parts = parts.split(".")
+
+ if not number_generator:
+ number_generator = getseries
+
series_set = False
today = now_datetime()
for e in parts:
@@ -200,7 +298,7 @@ def parse_naming_series(parts, doctype="", doc=""):
if e.startswith("#"):
if not series_set:
digits = len(e)
- part = getseries(n, digits)
+ part = number_generator(name, digits)
series_set = True
elif e == "YY":
part = today.strftime("%y")
@@ -225,9 +323,9 @@ def parse_naming_series(parts, doctype="", doc=""):
part = e
if isinstance(part, str):
- n += part
+ name += part
- return n
+ return name
def determine_consecutive_week_number(datetime):
@@ -311,14 +409,15 @@ def revert_series_if_last(key, name, doc=None):
frappe.db.sql("UPDATE `tabSeries` SET `current` = `current` - 1 WHERE `name`=%s", prefix)
-def get_default_naming_series(doctype):
+def get_default_naming_series(doctype: str) -> Optional[str]:
"""get default value for `naming_series` property"""
- naming_series = frappe.get_meta(doctype).get_field("naming_series").options or ""
- if naming_series:
- naming_series = naming_series.split("\n")
- return naming_series[0] or naming_series[1]
- else:
- return None
+ naming_series_options = frappe.get_meta(doctype).get_naming_series_options()
+
+ # Return first truthy options
+ # Empty strings are used to avoid populating forms by default
+ for option in naming_series_options:
+ if option:
+ return option
def validate_name(doctype: str, name: Union[int, str], case: Optional[str] = None):
diff --git a/frappe/modules/utils.py b/frappe/modules/utils.py
index 60eaefddc5..ad6a75e900 100644
--- a/frappe/modules/utils.py
+++ b/frappe/modules/utils.py
@@ -207,13 +207,18 @@ def export_doc(doctype, name, module=None):
write_document_file(frappe.get_doc(doctype, name), module)
-def get_doctype_module(doctype):
+def get_doctype_module(doctype) -> str:
"""Returns **Module Def** name of given doctype."""
def make_modules_dict():
return dict(frappe.db.sql("select name, module from tabDocType"))
- return frappe.cache().get_value("doctype_modules", make_modules_dict)[doctype]
+ doctype_module_map = frappe.cache().get_value("doctype_modules", make_modules_dict)
+
+ if module_name := doctype_module_map.get(doctype):
+ return module_name
+ else:
+ frappe.throw(_("DocType {} not found").format(doctype), exc=frappe.DoesNotExistError)
doctype_python_modules = {}
@@ -234,9 +239,9 @@ def load_doctype_module(doctype, module=None, prefix="", suffix=""):
if key not in doctype_python_modules:
doctype_python_modules[key] = frappe.get_module(module_name)
except ImportError as e:
- raise ImportError(
- "Module import failed for {0} ({1})".format(doctype, module_name + " Error: " + str(e))
- )
+ msg = f"Module import failed for {doctype}, the DocType you're trying to open might be deleted."
+ msg += f" Error: {e}"
+ raise ImportError(msg) from e
return doctype_python_modules[key]
@@ -251,12 +256,15 @@ def get_module_name(doctype, module, prefix="", suffix="", app=None):
)
-def get_module_app(module):
- return frappe.local.module_app[scrub(module)]
+def get_module_app(module: str) -> str:
+ app = frappe.local.module_app.get(scrub(module))
+ if app is None:
+ frappe.throw(_("Module {} not found").format(module), exc=frappe.DoesNotExistError)
+ return app
-def get_app_publisher(module):
- app = frappe.local.module_app[scrub(module)]
+def get_app_publisher(module: str) -> str:
+ app = get_module_app(module)
if not app:
frappe.throw(_("App not found"))
app_publisher = frappe.get_hooks(hook="app_publisher", app_name=app)[0]
@@ -321,7 +329,7 @@ def make_boilerplate(template, doc, opts=None):
base_class=base_class,
doctype=doc.name,
**opts,
- custom_controller=custom_controller
+ custom_controller=custom_controller,
)
)
)
diff --git a/frappe/printing/page/print_format_builder/print_format_builder.js b/frappe/printing/page/print_format_builder/print_format_builder.js
index 313e8da539..d12a7cc96c 100644
--- a/frappe/printing/page/print_format_builder/print_format_builder.js
+++ b/frappe/printing/page/print_format_builder/print_format_builder.js
@@ -441,7 +441,7 @@ frappe.PrintFormatBuilder = class PrintFormatBuilder {
const field = $(e.currentTarget).parent();
// new dialog
var d = new frappe.ui.Dialog({
- title: "Set Properties",
+ title: __("Set Properties"),
fields: [
{
label: __("Label"),
@@ -452,7 +452,8 @@ frappe.PrintFormatBuilder = class PrintFormatBuilder {
label: __("Align Value"),
fieldname: "align",
fieldtype: "Select",
- options: [{'label': __('Left'), 'value': 'left'}, {'label': __('Right'), 'value': 'right'}]
+ options: [{'label': __('Left', null, 'alignment'), 'value': 'left'},
+ {'label': __('Right', null, 'alignment'), 'value': 'right'}]
},
{
label: __("Remove Field"),
diff --git a/frappe/public/js/frappe/form/controls/html.js b/frappe/public/js/frappe/form/controls/html.js
index b2f18d4ccc..4cc0e4ab50 100644
--- a/frappe/public/js/frappe/form/controls/html.js
+++ b/frappe/public/js/frappe/form/controls/html.js
@@ -28,5 +28,6 @@ frappe.ui.form.ControlHTML = class ControlHTML extends frappe.ui.form.Control {
this.df.options = html;
this.html(html);
}
+ return Promise.resolve();
}
};
diff --git a/frappe/public/js/frappe/ui/field_group.js b/frappe/public/js/frappe/ui/field_group.js
index cba702407a..c226c4cbfb 100644
--- a/frappe/public/js/frappe/ui/field_group.js
+++ b/frappe/public/js/frappe/ui/field_group.js
@@ -132,7 +132,7 @@ frappe.ui.FieldGroup = class FieldGroup extends frappe.ui.form.Layout {
var f = this.fields_dict[key];
if (f) {
f.set_value(val).then(() => {
- f.set_input(val);
+ f.set_input?.(val);
this.refresh_dependency();
resolve();
});
diff --git a/frappe/public/js/print_format_builder/PrintFormatControls.vue b/frappe/public/js/print_format_builder/PrintFormatControls.vue
index 2eefc22409..7a4e9c81e7 100644
--- a/frappe/public/js/print_format_builder/PrintFormatControls.vue
+++ b/frappe/public/js/print_format_builder/PrintFormatControls.vue
@@ -181,8 +181,8 @@ export default {
return [
{ label: __("Top"), fieldname: "margin_top" },
{ label: __("Bottom"), fieldname: "margin_bottom" },
- { label: __("Left"), fieldname: "margin_left" },
- { label: __("Right"), fieldname: "margin_right" }
+ { label: __("Left", null, 'alignment'), fieldname: "margin_left" },
+ { label: __("Right", null, 'alignment'), fieldname: "margin_right" }
];
},
fields() {
diff --git a/frappe/public/scss/desk/report.scss b/frappe/public/scss/desk/report.scss
index f8666602ff..8ed0fb740c 100644
--- a/frappe/public/scss/desk/report.scss
+++ b/frappe/public/scss/desk/report.scss
@@ -104,12 +104,12 @@
}
.group-by-button.btn-primary-light {
- color: var(--blue-500);
+ color: var(--text-on-blue);
}
.group-by-icon.active {
use {
- stroke: var(--blue-500);
+ stroke: var(--text-on-blue);
}
}
diff --git a/frappe/public/scss/website/index.scss b/frappe/public/scss/website/index.scss
index f25e4d6cc6..109bc8cbb4 100644
--- a/frappe/public/scss/website/index.scss
+++ b/frappe/public/scss/website/index.scss
@@ -125,6 +125,10 @@
align-items: center;
}
+.page_content {
+ min-height: 50vh;
+}
+
.breadcrumb-container {
margin-top: 1rem;
padding-top: 0.25rem;
diff --git a/frappe/query_builder/terms.py b/frappe/query_builder/terms.py
index 8d64d2ddcd..64a4707983 100644
--- a/frappe/query_builder/terms.py
+++ b/frappe/query_builder/terms.py
@@ -100,7 +100,7 @@ class ParameterizedFunction(Function):
return function_sql
-class subqry(Criterion):
+class SubQuery(Criterion):
def __init__(
self,
subq: QueryBuilder,
@@ -112,3 +112,6 @@ class subqry(Criterion):
def get_sql(self, **kwg: Any) -> str:
kwg["subquery"] = True
return self.subq.get_sql(**kwg)
+
+
+subqry = SubQuery
diff --git a/frappe/share.py b/frappe/share.py
index 01d1412b8d..3edcb1be38 100644
--- a/frappe/share.py
+++ b/frappe/share.py
@@ -93,7 +93,7 @@ def set_permission(doctype, name, user, permission_to, value=1, everyone=0):
if not (share.read or share.write or share.submit or share.share):
share.delete()
- share = {}
+ share = None
return share
diff --git a/frappe/templates/includes/integrations/razorpay_checkout.js b/frappe/templates/includes/integrations/razorpay_checkout.js
index 2986fcb0fc..3df6ed68ea 100644
--- a/frappe/templates/includes/integrations/razorpay_checkout.js
+++ b/frappe/templates/includes/integrations/razorpay_checkout.js
@@ -3,6 +3,7 @@ $(document).ready(function(){
var options = {
"key": "{{ api_key }}",
"amount": cint({{ amount }} * 100), // 2000 paise = INR 20
+ "currency": "{{ currency }}",
"name": "{{ title }}",
"description": "{{ description }}",
"subscription_id": "{{ subscription_id }}",
diff --git a/frappe/templates/pages/integrations/razorpay_checkout.py b/frappe/templates/pages/integrations/razorpay_checkout.py
index aed832119b..b4f9e74a03 100644
--- a/frappe/templates/pages/integrations/razorpay_checkout.py
+++ b/frappe/templates/pages/integrations/razorpay_checkout.py
@@ -17,6 +17,7 @@ expected_keys = (
"payer_name",
"payer_email",
"order_id",
+ "currency",
)
diff --git a/frappe/templates/styles/card_style.css b/frappe/templates/styles/card_style.css
index 9e38ad70bf..a9639d8133 100644
--- a/frappe/templates/styles/card_style.css
+++ b/frappe/templates/styles/card_style.css
@@ -2,33 +2,32 @@
background-color: var(--bg-color);
}
-
-
.page-card {
- max-width: 360px;
- padding: 15px;
- margin: 70px auto;
- border-radius: 4px;
- background-color: var(--fg-color);
- /* box-shadow: var(--shadow-base); */
+ max-width: 360px;
+ padding: 15px;
+ margin: 70px auto;
+ border-radius: 4px;
+ background-color: var(--fg-color);
+ box-shadow: var(--shadow-base);
}
.for-reset-password {
- margin: 80px 0;
+ margin: 80px 0;
}
.for-reset-password .page-card {
- border: 0;
- max-width: 450px;
- margin: auto;
- border-radius: 10px;
+ border: 0;
+ max-width: 450px;
+ margin: auto;
+ border-radius: var(--border-radius-md);
+ padding: 40px 60px;
}
-@media (min-width: 567px) {
+@media (max-width: 425px) {
.for-reset-password .page-card {
- box-shadow: var(--shadow-base);
- padding: 40px 60px;
-
+ box-shadow: none;
+ background: none;
+ padding: 0px;
}
}
diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py
index dd67d68cd2..c1b2e05266 100644
--- a/frappe/tests/test_db_query.py
+++ b/frappe/tests/test_db_query.py
@@ -35,6 +35,35 @@ class TestReportview(unittest.TestCase):
clear_custom_fields("DocType")
+ def test_child_table_field_syntax(self):
+ note = frappe.get_doc(
+ doctype="Note",
+ title=f"Test {frappe.utils.random_string(8)}",
+ content="test",
+ seen_by=[{"user": "Administrator"}],
+ ).insert()
+ result = frappe.db.get_all(
+ "Note",
+ filters={"name": note.name},
+ fields=["name", "seen_by.user as seen_by"],
+ limit=1,
+ )
+ self.assertEqual(result[0].seen_by, "Administrator")
+ note.delete()
+
+ def test_link_field_syntax(self):
+ todo = frappe.get_doc(
+ doctype="ToDo", description="Test ToDo", allocated_to="Administrator"
+ ).insert()
+ result = frappe.db.get_all(
+ "ToDo",
+ filters={"name": todo.name},
+ fields=["name", "allocated_to.email as allocated_user_email"],
+ limit=1,
+ )
+ self.assertEqual(result[0].allocated_user_email, "admin@example.com")
+ todo.delete()
+
def test_build_match_conditions(self):
clear_user_permissions_for_doctype("Blog Post", "test2@example.com")
diff --git a/frappe/tests/test_naming.py b/frappe/tests/test_naming.py
index e217f24154..d966fd5ce8 100644
--- a/frappe/tests/test_naming.py
+++ b/frappe/tests/test_naming.py
@@ -1,20 +1,21 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
-import unittest
-
import frappe
from frappe.core.doctype.doctype.test_doctype import new_doctype
from frappe.model.naming import (
+ InvalidNamingSeriesError,
+ NamingSeries,
append_number_if_name_exists,
determine_consecutive_week_number,
getseries,
revert_series_if_last,
)
+from frappe.tests.utils import FrappeTestCase
from frappe.utils import now_datetime
-class TestNaming(unittest.TestCase):
+class TestNaming(FrappeTestCase):
def setUp(self):
frappe.db.delete("Note")
@@ -52,16 +53,13 @@ class TestNaming(unittest.TestCase):
self.assertEqual(country.name, country.country_name)
def test_child_table_naming(self):
- child_dt_with_naming = new_doctype(
- "childtable_with_autonaming", istable=1, autoname="field:some_fieldname"
- ).insert()
+ child_dt_with_naming = new_doctype(istable=1, autoname="field:some_fieldname").insert()
dt_with_child_autoname = new_doctype(
- "dt_with_childtable_naming",
fields=[
{
"label": "table with naming",
"fieldname": "table_with_naming",
- "options": "childtable_with_autonaming",
+ "options": child_dt_with_naming.name,
"fieldtype": "Table",
}
],
@@ -69,7 +67,7 @@ class TestNaming(unittest.TestCase):
name = frappe.generate_hash(length=10)
- doc = frappe.new_doc("dt_with_childtable_naming")
+ doc = frappe.new_doc(dt_with_child_autoname.name)
doc.append("table_with_naming", {"some_fieldname": name})
doc.save()
self.assertEqual(doc.table_with_naming[0].name, name)
@@ -89,31 +87,18 @@ class TestNaming(unittest.TestCase):
"""
Test if braced params are replaced in format autoname
"""
- doctype = "ToDo"
-
- todo_doctype = frappe.get_doc("DocType", doctype)
- todo_doctype.autoname = "format:TODO-{MM}-{status}-{##}"
- todo_doctype.save()
+ doctype = new_doctype(autoname="format:TODO-{MM}-{some_fieldname}-{##}").insert()
description = "Format"
- todo = frappe.new_doc(doctype)
- todo.description = description
- todo.insert()
+ doc = frappe.new_doc(doctype.name)
+ doc.some_fieldname = description
+ doc.insert()
series = getseries("", 2)
+ series = int(series) - 1
- series = str(int(series) - 1)
-
- if len(series) < 2:
- series = "0" + series
-
- self.assertEqual(
- todo.name,
- "TODO-{month}-{status}-{series}".format(
- month=now_datetime().strftime("%m"), status=todo.status, series=series
- ),
- )
+ self.assertEqual(doc.name, f"TODO-{now_datetime().strftime('%m')}-{description}-{series:02}")
def test_format_autoname_for_consecutive_week_number(self):
"""
@@ -303,6 +288,46 @@ class TestNaming(unittest.TestCase):
dt.delete(ignore_permissions=True)
+ def test_naming_series_prefix(self):
+ today = now_datetime()
+ year = today.strftime("%y")
+ month = today.strftime("%m")
+
+ prefix_test_cases = {
+ "SINV-.YY.-.####": f"SINV-{year}-",
+ "SINV-.YY.-.MM.-.####": f"SINV-{year}-{month}-",
+ "SINV": "SINV",
+ "SINV-.": "SINV-",
+ }
+
+ for series, prefix in prefix_test_cases.items():
+ self.assertEqual(prefix, NamingSeries(series).get_prefix())
+
+ def test_naming_series_validation(self):
+ dns = frappe.get_doc("Document Naming Settings")
+ exisiting_series = dns.get_transactions_and_prefixes()["prefixes"]
+ valid = ["SINV-", "SI-.{field}.", "SI-#.###", ""] + exisiting_series
+ invalid = ["$INV-", r"WINDOWS\NAMING"]
+
+ for series in valid:
+ if series.strip():
+ try:
+ NamingSeries(series).validate()
+ except Exception as e:
+ self.fail(f"{series} should be valid\n{e}")
+
+ for series in invalid:
+ self.assertRaises(InvalidNamingSeriesError, NamingSeries(series).validate)
+
+ def test_naming_using_fields(self):
+
+ webhook = frappe.new_doc("Webhook")
+ webhook.webhook_docevent = "on_update"
+ name = NamingSeries("KOOH-.{webhook_docevent}.").generate_next_name(webhook)
+ self.assertTrue(
+ name.startswith("KOOH-on_update"), f"incorrect name generated {name}, missing field value"
+ )
+
def make_invalid_todo():
frappe.get_doc({"doctype": "ToDo", "description": "Test"}).insert(set_name="ToDo")
diff --git a/frappe/tests/test_utils.py b/frappe/tests/test_utils.py
index 04f9d16fd1..4f4fca8bbf 100644
--- a/frappe/tests/test_utils.py
+++ b/frappe/tests/test_utils.py
@@ -615,3 +615,29 @@ class TestAppParser(unittest.TestCase):
self.assertEqual("healthcare", parse_app_name("https://github.com/frappe/healthcare.git"))
self.assertEqual("healthcare", parse_app_name("git@github.com:frappe/healthcare.git"))
self.assertEqual("healthcare", parse_app_name("frappe/healthcare@develop"))
+
+
+class TestIntrospectionMagic(unittest.TestCase):
+ """Test utils that inspect live objects"""
+
+ def test_get_newargs(self):
+ # `kwargs` is just convention any **varname should work.
+ def f(a, b=2, **args):
+ pass
+
+ safe_kwargs = {"company": "Wind Power", "b": 1}
+ self.assertEqual(frappe.get_newargs(f, safe_kwargs), safe_kwargs)
+
+ unsafe_args = dict(safe_kwargs)
+ unsafe_args.update({"ignore_permissions": True, "flags": {"ignore_mandatory": True}})
+ self.assertEqual(frappe.get_newargs(f, unsafe_args), safe_kwargs)
+
+ def test_strip_off_kwargs_when_not_supported(self):
+ def f(a, b=2):
+ pass
+
+ args = {"company": "Wind Power", "b": 1}
+ self.assertEqual(frappe.get_newargs(f, args), {"b": 1})
+
+ # No args
+ self.assertEqual(frappe.get_newargs(lambda: None, args), {})
diff --git a/frappe/tests/test_website.py b/frappe/tests/test_website.py
index 37ac611b4e..9478c4cf5f 100644
--- a/frappe/tests/test_website.py
+++ b/frappe/tests/test_website.py
@@ -118,7 +118,7 @@ class TestWebsite(unittest.TestCase):
def test_error_page(self):
set_request(method="GET", path="/_test/problematic_page")
response = get_response()
- self.assertEqual(response.status_code, 500)
+ self.assertEqual(response.status_code, 417)
def test_login(self):
set_request(method="GET", path="/login")
diff --git a/frappe/tests/utils.py b/frappe/tests/utils.py
index 7d00a0c1f9..fc26694d46 100644
--- a/frappe/tests/utils.py
+++ b/frappe/tests/utils.py
@@ -82,6 +82,8 @@ def _restore_thread_locals(flags):
frappe.local.realtime_log = []
frappe.local.conf = frappe._dict(frappe.get_site_config())
frappe.local.cache = {}
+ frappe.local.lang = "en"
+ frappe.local.lang_full_dict = None
@contextmanager
diff --git a/frappe/translations/fr.csv b/frappe/translations/fr.csv
index d1dba64a44..f17585fe88 100644
--- a/frappe/translations/fr.csv
+++ b/frappe/translations/fr.csv
@@ -4717,3 +4717,6 @@ Document has been cancelled,Document annulé
Document is in draft state,Document au statut brouillon
Copy to Clipboard,Copier vers le presse-papiers
Don't have an account?,Vous n'avez pas de compte?
+Left:alignment,Gauche
+Right:alignment,Droite
+Set Properties,Gérer les proriétés
diff --git a/frappe/translations/ru.csv b/frappe/translations/ru.csv
index 3fdeab5546..94a87bdcf8 100644
--- a/frappe/translations/ru.csv
+++ b/frappe/translations/ru.csv
@@ -840,7 +840,7 @@ Default Sending and Inbox,По умолчанию отправка и получ
Default Sort Field,Поле сортировки по умолчанию,
Default Sort Order,Порядок сортировки по умолчанию,
Default Value,Значение по умолчанию,
-"Default: ""Contact Us""","По умолчанию: ""Обратная связь""",
+"Default: ""Contact Us""","По умолчанию: ""Contact Us""",
DefaultValue,DefaultValue,
Define workflows for forms.,Определите рабочие процессы для форм.,
Defines actions on states and the next step and allowed roles.,"Определяет действия на статусах, следующий шаг и роли, обладающие правами перевода статусов.",
@@ -849,7 +849,7 @@ Delayed,Задерживается,
Delete Data,Удалить данные,
Delete comment?,Удалить комментарий?,
Delete this record to allow sending to this email address,"Удалить эту запись, чтобы разрешить отправку на этот адрес электронной почты",
-Delete {0} items permanently?,Удалить {0} продуктов навсегда?,
+Delete {0} items permanently?,Удалить {0} объектов навсегда?,
Deleted,Удаленный,
Deleted DocType,Удаленный DocType,
Deleted Document,Удаленный документ,
@@ -914,7 +914,7 @@ Document can't saved.,Документ не может быть сохранен
Document {0} has been set to state {1} by {2},Документ {0} установлен в состояние {1} на {2},
Documents,Документы,
Documents assigned to you and by you.,"Документы, назначенные вам и вами.",
-Domain Settings,Настройки домена,
+Domain Settings,Настройка сфер деятельности,
Domains HTML,Домены HTML,
"Don't HTML Encode HTML tags like <script> or just characters like < or >, as they could be intentionally used in this field","Не HTML Кодировать HTML-теги, такие как <скрипт> или просто символы, такие как <или>, так как они могут быть преднамеренно использованы в этой области",
Don't Override Status,Не переопределять статус,
@@ -990,7 +990,7 @@ Enable Auto Reply,Включить автоматический ответ,
Enable Automatic Backup,Включить автоматическое резервное копирование,
Enable Chat,Включить чат,
Enable Comments,Включить комментарии,
-Enable Incoming,Включение входящей,
+Enable Incoming,Включить входящие,
Enable Outgoing,Включить исходящие,
Enable Password Policy,Включить политику паролей,
Enable Print Server,Включить сервер печати,
@@ -1000,7 +1000,7 @@ Enable Scheduled Jobs,Включить запланированных задан
Enable Social Login,Включить социальный вход,
Enable Two Factor Auth,Включить двухфакторный аут,
Enabled email inbox for user {0},Включен почтовый ящик для пользователя {0},
-"Encryption key is invalid, Please check site_config.json","Ключ шифрования недействителен, проверьте сайт_config.json",
+"Encryption key is invalid, Please check site_config.json","Ключ шифрования недействителен, проверьте site_config.json",
End Date Field,Поле конечной даты,
End Date cannot be before Start Date!,Дата окончания не может быть до даты начала!,
Endpoint URL,URL конечной точки,
@@ -1029,8 +1029,8 @@ Error in Notification: {},Ошибка в уведомлении: {},
Error while connecting to email account {0},Ошибка при подключении к учетной записи электронной почты {0},
Error while evaluating Notification {0}. Please fix your template.,Ошибка при оценке уведомления {0}. Исправьте шаблон.,
Error: Document has been modified after you have opened it,"Ошибка: документ был изменен после того, как вы открыли его",
-Error: Value missing for {0}: {1},Ошибка: значение отсутствует для {0}: {1},
-Errors in Background Events,Ошибки в фоновых событий,
+Error: Value missing for {0}: {1},Ошибка: отсутствует значение для {0}: {1},
+Errors in Background Events,Ошибки в фоновых событиях,
Event Category,Категория события,
Event Participants,Участники мероприятия,
Event Type,Тип события,
@@ -1184,7 +1184,7 @@ Get Contacts,Получить контакты,
Get Fields,Получить поля,
Get your globally recognized avatar from Gravatar.com,Получить всемирно признанный аватара из Gravatar.com,
GitHub,GitHub,
-Give Review Points,Дайте очки обзора,
+Give Review Points,Дайте баллы обзора,
Global Unsubscribe,Глобальная отписка,
Go to the document,Перейти к документу,
Go to this URL after completing the form (only for Guest users),Перейдите по этому URL-адресу после заполнения формы (только для гостевых пользователей),
@@ -1461,8 +1461,8 @@ Letter Head Name,Название заголовка письма,
Letter Head in HTML,Заголовок письма в HTML,
Level Name,Название уровня,
Liked,Понравилось,
-Liked By,В избранное К,
-Liked by {0},В избранное {0},
+Liked By,Нравится,
+Liked by {0},Нравится {0},
Likes,Понравившееся,
Limit Number of DB Backups,Ограничение количества резервных копий БД,
Line,Линия,
@@ -1470,7 +1470,7 @@ Link DocType,Ссылка DocType,
Link Expired,Срок действия ссылки,
Link Name,Имя ссылки,
Link Title,Название ссылки,
-"Link that is the website home page. Standard Links (index, login, products, blog, about, contact)","Ссылка, которая является стартовой страницей сайта. Стандартные ссылки (индекс, логин, продукты, блог, о, контакт)",
+"Link that is the website home page. Standard Links (index, login, products, blog, about, contact)","Ссылка, которая является стартовой страницей сайта. Стандартные ссылки (index, login, products, blog, about, contact)",
Link to the page you want to open. Leave blank if you want to make it a group parent.,"Ссылка на страницу, которую вы хотите открыть. Оставьте пустым, если хотите сделать его родительским элементом группы.",
Linked,Связанный,
Linked With,Связанные с,
@@ -2096,7 +2096,7 @@ Revert Of,Вернуть из,
Reverted,Отменено,
Review Level,Уровень обзора,
Review Levels,Уровни обзора,
-Review Points,Очки обзора,
+Review Points,Баллы обзора,
Reviews,Отзывы,
Revoke,Аннулировать,
Revoked,Аннулировано,
@@ -2141,10 +2141,10 @@ SMS sent to following numbers: {0},SMS отправлено следующим
SMTP Server,SMTP-сервер,
SMTP Settings for outgoing emails,Настройки SMTP для исходящих писем,
"SQL Conditions. Example: status=""Open""",SQL условия. Пример: статус = "Открыть",
-SSL/TLS Mode,Режим SSL / TLS,
+SSL/TLS Mode,Режим SSL/TLS,
Salesforce,Salesforce,
Same Field is entered more than once,Одно и то же поле вводится не один раз,
-Save API Secret: ,Сохранить API-интерфейс:,
+Save API Secret: ,Сохранить API секрет: ,
Save As,Сохранить как,
Save Filter,Сохранить фильтр,
Save Report,Сохранить отчет,
@@ -2258,7 +2258,7 @@ Set Property After Alert,Задать свойство после оповеще
Set Quantity,Установите Количество,
Set Role For,Установить роль для,
Set User Permissions,Задание разрешений пользователя,
-Set Value,Задать значение,
+Set Value,Установить значение,
Set custom roles for page and report,Набор пользовательских ролей для страницы и отчета,
"Set default format, page size, print style etc.","Установить форму, размер страницы, стиль печати и т.д., используюмых по умолчанию",
Set non-standard precision for a Float or Currency field,Установите нестандартные точность для поплавка или валютной области,
@@ -2337,7 +2337,7 @@ Slideshow like display for the website,"Слайд-шоу, как дисплей
Small Text,Маленьикий текст,
Smallest Currency Fraction Value,Минимальное дробное значение,
Smallest circulating fraction unit (coin). For e.g. 1 cent for USD and it should be entered as 0.01,"Минимальная разменная денежная единица (монета). Например, для доллара — 1 цент, и его нужно ввести как 0,01",
-Snapshot View,Снимок Посмотреть,
+Snapshot View,Просмотр снимка,
Social,Сообщество,
Social Login Key,Ключ социального входа,
Social Login Provider,Социальный провайдер,
@@ -2418,7 +2418,7 @@ Suspend Sending,Приостановить Отправка,
Switch To Desk,Переключение на рабочий стол,
Symbol,Символ,
Sync,Синхронизация,
-Sync on Migrate,Синхронизация по Migrate,
+Sync on Migrate,Синхронизировать при переносе,
Syntax error in template,Синтаксическая ошибка в шаблоне,
System,Система,
System Page,Страница системы,
@@ -2450,7 +2450,7 @@ Thank you for your interest in subscribing to our updates,Спасибо за в
Thank you for your message,Спасибо за ваше сообщение,
The CSV format is case sensitive,Формат CSV чувствителен к регистру,
The Condition '{0}' is invalid,Условие '{0}' является недействительным,
-The First User: You,Первый пользователя: Вы,
+The First User: You,Первый пользователь: Вы,
"The application has been updated to a new version, please refresh this page","Приложение был обновлен до новой версии, пожалуйста, обновите эту страницу",
The attachments could not be correctly linked to the new document,Вложения не могут быть правильно связаны с новым документом,
The document could not be correctly assigned,Документ не может быть правильно назначен,
@@ -2653,7 +2653,7 @@ User Field,Поле пользователя,
User ID of a Blogger,ID пользователя-блоггера,
User Image,Изображение пользователя,
User Name,Имя пользователя,
-User Permission,Пользователь Введено,
+User Permission,Разрешения пользователя,
User Permissions,Разрешения пользователей,
User Permissions are used to limit users to specific records.,Пользовательские разрешения используются для ограничения пользователей конкретными записями.,
User Permissions created sucessfully,Пользовательские разрешения созданы успешно,
@@ -3068,8 +3068,8 @@ zoom-out,отдалить,
{0} or {1},{0} или {1},
{0} record deleted,{0} запись удалена,
{0} records deleted,{0} записей удалено,
-{0} reverted your point on {1},{0} вернул вашу точку на {1},
-{0} reverted your points on {1},{0} вернул ваши очки на {1},
+{0} reverted your point on {1},{0} вернул ваш балл на {1},
+{0} reverted your points on {1},{0} вернул ваши баллы на {1},
{0} reverted {1},{0} вернул {1},
{0} room must have atmost one user.,{0} номер должен иметь самого одного пользователя.,
{0} rows for {1},{0} строк для {1},
@@ -3148,7 +3148,7 @@ Access not allowed from this IP Address,Доступ с этого IP-адрес
Action Type,Тип действия,
Activity Log by ,Активность Журнал по,
Add Fields,Добавить поля,
-Administration,Администрация,
+Administration,Администрирование,
After Cancel,После отмены,
After Delete,После удаления,
After Save,После сохранения,
@@ -3157,7 +3157,7 @@ After Submit,После отправки,
Aggregate Function Based On,"Агрегатная функция, основанная на",
Aggregate Function field is required to create a dashboard chart,Поле Aggregate Function необходимо для создания диаграммы панели мониторинга.,
All Records,Все записи,
-Allot Points To Assigned Users,Выделить очки назначенным пользователям,
+Allot Points To Assigned Users,Выделить баллы назначенным пользователям,
Allow Auto Repeat,Разрешить автоматическое повторение,
Allow Google Calendar Access,Разрешить доступ к Календарю Google,
Allow Google Contacts Access,Разрешить доступ к контактам Google,
@@ -3380,7 +3380,7 @@ Invalid field name: {0},Неверное имя поля: {0},
Invalid file URL. Please contact System Administrator.,"Неверный URL файла. Пожалуйста, свяжитесь с системным администратором.",
Invalid include path,Неверный путь включения,
Invalid username or password,неправильное имя пользователя или пароль,
-Is Primary,Первичный,
+Is Primary,Основной,
Is Primary Mobile,Основной мобильный,
Is Primary Phone,Основной телефон,
Is Tree,Дерево,
@@ -3667,7 +3667,7 @@ via Data Import,через импорт данных,
{0} are mandatory fields,{0} обязательные поля,
{0} are required,{0} требуется,
{0} assigned a new task {1} {2} to you,{0} назначил вам новое задание {1} {2},
-{0} gained {1} point for {2} {3},{0} набрал {1} очко за {2} {3},
+{0} gained {1} point for {2} {3},{0} получил {1} балл за {2} {3},
{0} gained {1} points for {2} {3},{0} набрал {1} баллов за {2} {3},
{0} has no versions tracked.,{0} не отслеживает версии.,
{0} is not a valid report format. Report format should one of the following {1},{0} не является допустимым форматом отчета. Формат отчета должен быть одним из следующих {1},
@@ -4045,7 +4045,7 @@ No Permitted Charts on this Dashboard,На этой панели инструм
No Permitted Charts,Нет разрешенных графиков,
Reset Chart,Сбросить график,
via {0},через {0},
-{0} is not a valid Phone Number,{0} не является действительным номером телефона,
+{0} is not a valid Phone Number,{0} недействительный номер телефона,
Failed Transactions,Неудачные транзакции,
Value for field {0} is too long in {1}. Length should be lesser than {2} characters,Значение поля {0} слишком длинное в {1}. Длина должна быть меньше {2} симв.,
Data Too Long,Данные слишком длинные,
@@ -4121,8 +4121,8 @@ Using this console may allow attackers to impersonate you and steal your informa
{0} w,{0} н,
{0} M,{0} М,
{0} y,{0} г,
-yesterday,вчерашний день,
-{0} years ago,{0} лет назад,
+yesterday,вчера,
+{0} years ago,{0} год назад,
New Chart,Новый график,
New Shortcut,Новый ярлык,
Edit Chart,Изменить диаграмму,
@@ -4700,3 +4700,11 @@ Value cannot be negative for {0}: {1},Значение не может быть
Negative Value,Отрицательное значение,
Authentication failed while receiving emails from Email Account: {0}.,Ошибка аутентификации при получении писем из учетной записи электронной почты: {0}.,
Message from server: {0},Сообщение с сервера: {0},
+Documentation,Документация,
+User Forum,Форум пользователей,
+Report an issue,Сообщить об ошибке,
+My Profile,Мой профиль,
+My Settings,Мои настройки,
+Toggle Full Width,Переключить ширину,
+Toggle Theme,Переключить тему,
+Modules,Модули,
diff --git a/frappe/twofactor.py b/frappe/twofactor.py
index 5a2799bc54..9e9a2c5d76 100644
--- a/frappe/twofactor.py
+++ b/frappe/twofactor.py
@@ -277,7 +277,9 @@ def get_email_subject_for_qr_code(kwargs_dict):
def get_email_body_for_qr_code(kwargs_dict):
"""Get QRCode email body."""
- body_template = "Please click on the following link and follow the instructions on the page.
{{qrcode_link}}"
+ body_template = _(
+ "Please click on the following link and follow the instructions on the page. {0}"
+ ).format("
{{qrcode_link}}")
body = frappe.render_template(body_template, kwargs_dict)
return body
diff --git a/frappe/utils/data.py b/frappe/utils/data.py
index 6a9ffc81a6..bf030020ac 100644
--- a/frappe/utils/data.py
+++ b/frappe/utils/data.py
@@ -1887,6 +1887,16 @@ def strip(val: str, chars: Optional[str] = None) -> str:
return (val or "").replace("\ufeff", "").replace("\u200b", "").strip(chars)
+def get_string_between(start: str, string: str, end: str) -> str:
+ if not string:
+ return ""
+
+ regex = "{0}(.*){1}".format(start, end)
+ out = re.search(regex, string)
+
+ return out.group(1) if out else string
+
+
def to_markdown(html: str) -> str:
from html.parser import HTMLParser
diff --git a/frappe/utils/nestedset.py b/frappe/utils/nestedset.py
index d3067973ef..681cd6439d 100644
--- a/frappe/utils/nestedset.py
+++ b/frappe/utils/nestedset.py
@@ -17,6 +17,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.query_builder import Order
from frappe.query_builder.functions import Coalesce, Max
+from frappe.query_builder.terms import SubQuery
from frappe.query_builder.utils import DocType
@@ -336,14 +337,15 @@ class NestedSet(Document):
def get_root_of(doctype):
"""Get root element of a DocType with a tree structure"""
from frappe.query_builder.functions import Count
- from frappe.query_builder.terms import subqry
Table = DocType(doctype)
t1 = Table.as_("t1")
t2 = Table.as_("t2")
- subq = frappe.qb.from_(t2).select(Count("*")).where((t2.lft < t1.lft) & (t2.rgt > t1.rgt))
- result = frappe.qb.from_(t1).select(t1.name).where((subqry(subq) == 0) & (t1.rgt > t1.lft)).run()
+ node_query = SubQuery(
+ frappe.qb.from_(t2).select(Count("*")).where((t2.lft < t1.lft) & (t2.rgt > t1.rgt))
+ )
+ result = frappe.qb.from_(t1).select(t1.name).where((node_query == 0) & (t1.rgt > t1.lft)).run()
return result[0][0] if result else None
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 4f115325df..6518cda5ed 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
@@ -2,17 +2,17 @@
# Copyright (c) 2019, Frappe Technologies and Contributors
# License: MIT. See LICENSE
import json
-import unittest
import frappe
from frappe.contacts.doctype.contact.contact import get_contact_name
from frappe.core.doctype.user.user import create_contact
+from frappe.tests.utils import FrappeTestCase
from frappe.website.doctype.personal_data_download_request.personal_data_download_request import (
get_user_data,
)
-class TestRequestPersonalData(unittest.TestCase):
+class TestRequestPersonalData(FrappeTestCase):
def setUp(self):
create_user_if_not_exists(email="test_privacy@example.com")
@@ -48,7 +48,7 @@ class TestRequestPersonalData(unittest.TestCase):
email_queue = frappe.get_all(
"Email Queue", fields=["message"], order_by="creation DESC", limit=1
)
- self.assertTrue("Subject: Download Your Data" in email_queue[0].message)
+ self.assertIn(frappe._("Download Your Data"), email_queue[0].message)
frappe.db.delete("Email Queue")
diff --git a/frappe/website/doctype/website_settings/website_settings.json b/frappe/website/doctype/website_settings/website_settings.json
index b628437315..aa17fa261f 100644
--- a/frappe/website/doctype/website_settings/website_settings.json
+++ b/frappe/website/doctype/website_settings/website_settings.json
@@ -21,6 +21,7 @@
"website_theme_image_link",
"brand",
"banner_image",
+ "splash_image",
"brand_html",
"set_banner_from_image",
"favicon",
@@ -413,6 +414,11 @@
"fieldname": "footer_powered",
"fieldtype": "Small Text",
"label": "Footer \"Powered By\""
+ },
+ {
+ "fieldname": "splash_image",
+ "fieldtype": "Attach Image",
+ "label": "Splash Image"
}
],
"icon": "fa fa-cog",
@@ -420,7 +426,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2022-03-09 01:47:31.094462",
+ "modified": "2022-05-27 12:33:29.019998",
"modified_by": "Administrator",
"module": "Website",
"name": "Website Settings",
@@ -445,4 +451,4 @@
"sort_order": "ASC",
"states": [],
"track_changes": 1
-}
+}
\ No newline at end of file
diff --git a/frappe/website/doctype/website_settings/website_settings.py b/frappe/website/doctype/website_settings/website_settings.py
index e8f15290c4..7fb5d191ab 100644
--- a/frappe/website/doctype/website_settings/website_settings.py
+++ b/frappe/website/doctype/website_settings/website_settings.py
@@ -136,7 +136,7 @@ def get_website_settings(context=None):
}
)
- settings = frappe.get_single("Website Settings")
+ settings: "WebsiteSettings" = frappe.get_single("Website Settings")
for k in [
"banner_html",
"banner_image",
@@ -203,6 +203,9 @@ def get_website_settings(context=None):
context["hide_login"] = settings.hide_login
+ if splash_image := settings.splash_image or context.get("splash_image"):
+ context["splash_image"] = splash_image
+
return context
diff --git a/frappe/website/page_renderers/error_page.py b/frappe/website/page_renderers/error_page.py
index 613809bfdc..6a3925967c 100644
--- a/frappe/website/page_renderers/error_page.py
+++ b/frappe/website/page_renderers/error_page.py
@@ -5,7 +5,13 @@ class ErrorPage(TemplatePage):
def __init__(self, path=None, http_status_code=None, exception=None):
path = "error"
super().__init__(path=path, http_status_code=http_status_code)
- self.http_status_code = getattr(exception, "http_status_code", None) or http_status_code or 500
+ self.exception = exception
def can_render(self):
return True
+
+ def init_context(self):
+ super().init_context()
+ self.context.http_status_code = getattr(self.exception, "http_status_code", None) or 500
+ self.context.error_title = getattr(self.exception, "title", None)
+ self.context.error_message = getattr(self.exception, "message", None)
diff --git a/frappe/website/page_renderers/template_page.py b/frappe/website/page_renderers/template_page.py
index 2ed8a62119..83f68d3716 100644
--- a/frappe/website/page_renderers/template_page.py
+++ b/frappe/website/page_renderers/template_page.py
@@ -212,19 +212,13 @@ class TemplatePage(BaseTemplatePage):
def run_pymodule_method(self, method_name):
if hasattr(self.pymodule, method_name):
- try:
- import inspect
+ import inspect
- method = getattr(self.pymodule, method_name)
- if inspect.getfullargspec(method).args:
- return method(self.context)
- else:
- return method()
- except (frappe.PermissionError, frappe.DoesNotExistError, frappe.Redirect):
- raise
- except Exception:
- if not frappe.flags.in_migrate:
- frappe.errprint(frappe.utils.get_traceback())
+ method = getattr(self.pymodule, method_name)
+ if inspect.getfullargspec(method).args:
+ return method(self.context)
+ else:
+ return method()
def render_template(self):
if self.template_path.endswith("min.js"):
diff --git a/frappe/www/error.html b/frappe/www/error.html
index d63daec759..142897c35a 100644
--- a/frappe/www/error.html
+++ b/frappe/www/error.html
@@ -23,15 +23,15 @@
- {{_("Uncaught Server Exception")}}
+ {{ error_title }}