diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.js b/frappe/automation/doctype/auto_repeat/auto_repeat.js
index 7028ac486d..896a10dfe0 100644
--- a/frappe/automation/doctype/auto_repeat/auto_repeat.js
+++ b/frappe/automation/doctype/auto_repeat/auto_repeat.js
@@ -103,7 +103,7 @@ frappe.ui.form.on('Auto Repeat', {
frappe.auto_repeat.render_schedule = function(frm) {
if (!frm.is_dirty() && frm.doc.status !== 'Disabled') {
frm.call("get_auto_repeat_schedule").then(r => {
- frm.dashboard.wrapper.empty();
+ frm.dashboard.reset();
frm.dashboard.add_section(
frappe.render_template("auto_repeat_schedule", {
schedule_details: r.message || []
diff --git a/frappe/core/doctype/doctype/doctype.py b/frappe/core/doctype/doctype/doctype.py
index 3588cc553a..6eef5a4023 100644
--- a/frappe/core/doctype/doctype/doctype.py
+++ b/frappe/core/doctype/doctype/doctype.py
@@ -83,12 +83,61 @@ class DocType(Document):
if not self.is_new():
self.before_update = frappe.get_doc('DocType', self.name)
self.setup_fields_to_fetch()
+ self.validate_field_name_conflicts()
check_email_append_to(self)
if self.default_print_format and not self.custom:
frappe.throw(_('Standard DocType cannot have default print format, use Customize Form'))
+ if frappe.conf.get('developer_mode'):
+ self.owner = 'Administrator'
+ self.modified_by = 'Administrator'
+
+ def validate_field_name_conflicts(self):
+ """Check if field names dont conflict with controller properties and methods"""
+ core_doctypes = [
+ "Custom DocPerm",
+ "DocPerm",
+ "Custom Field",
+ "Customize Form Field",
+ "DocField",
+ ]
+
+ if self.name in core_doctypes:
+ return
+
+ from frappe.model.base_document import get_controller
+
+ try:
+ controller = get_controller(self.name)
+ except ImportError:
+ controller = Document
+
+ available_objects = {x for x in dir(controller) if isinstance(x, str)}
+ property_set = {
+ x for x in available_objects if isinstance(getattr(controller, x, None), property)
+ }
+ method_set = {
+ x for x in available_objects if x not in property_set and callable(getattr(controller, x, None))
+ }
+
+ for docfield in self.get("fields") or []:
+ conflict_type = None
+ field = docfield.fieldname
+ field_label = docfield.label or docfield.fieldname
+
+ if docfield.fieldname in method_set:
+ conflict_type = "controller method"
+ if docfield.fieldname in property_set:
+ conflict_type = "class property"
+
+ if conflict_type:
+ frappe.throw(
+ _("Fieldname '{0}' conflicting with a {1} of the name {2} in {3}")
+ .format(field_label, conflict_type, field, self.name)
+ )
+
def after_insert(self):
# clear user cache so that on the next reload this doctype is included in boot
clear_user_cache(frappe.session.user)
@@ -1174,11 +1223,19 @@ def make_module_and_roles(doc, perm_fieldname="permissions"):
else:
raise
-def check_if_fieldname_conflicts_with_methods(doctype, fieldname):
- doc = frappe.get_doc({"doctype": doctype})
- method_list = [method for method in dir(doc) if isinstance(method, str) and callable(getattr(doc, method))]
+def check_fieldname_conflicts(doctype, fieldname):
+ """Checks if fieldname conflicts with methods or properties"""
- if fieldname in method_list:
+ doc = frappe.get_doc({"doctype": doctype})
+ available_objects = [x for x in dir(doc) if isinstance(x, str)]
+ property_list = [
+ x for x in available_objects if isinstance(getattr(type(doc), x, None), property)
+ ]
+ method_list = [
+ x for x in available_objects if x not in property_list and callable(getattr(doc, x))
+ ]
+
+ if fieldname in method_list + property_list:
frappe.throw(_("Fieldname {0} conflicting with meta object").format(fieldname))
def clear_linked_doctype_cache():
diff --git a/frappe/custom/doctype/custom_field/custom_field.py b/frappe/custom/doctype/custom_field/custom_field.py
index fb49aa5da0..39aff8b4a7 100644
--- a/frappe/custom/doctype/custom_field/custom_field.py
+++ b/frappe/custom/doctype/custom_field/custom_field.py
@@ -64,8 +64,8 @@ class CustomField(Document):
self.translatable = 0
if not self.flags.ignore_validate:
- from frappe.core.doctype.doctype.doctype import check_if_fieldname_conflicts_with_methods
- check_if_fieldname_conflicts_with_methods(self.dt, self.fieldname)
+ from frappe.core.doctype.doctype.doctype import check_fieldname_conflicts
+ check_fieldname_conflicts(self.dt, self.fieldname)
def on_update(self):
if not frappe.flags.in_setup_wizard:
diff --git a/frappe/email/doctype/newsletter/newsletter..json b/frappe/email/doctype/newsletter/newsletter..json
deleted file mode 100644
index e69de29bb2..0000000000
diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py
index 05435482bd..154a091b8a 100644
--- a/frappe/model/base_document.py
+++ b/frappe/model/base_document.py
@@ -34,8 +34,9 @@ def get_controller(doctype):
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]
+ module_name, custom = frappe.db.get_value(
+ "DocType", doctype, ("module", "custom"), cache=True
+ ) or ["Core", False]
if custom:
if frappe.db.field_exists("DocType", "is_tree"):
diff --git a/frappe/model/naming.py b/frappe/model/naming.py
index 1a3f90da37..359b8e2367 100644
--- a/frappe/model/naming.py
+++ b/frappe/model/naming.py
@@ -199,10 +199,39 @@ def getseries(key, digits):
def revert_series_if_last(key, name, doc=None):
- if ".#" in key:
+ """
+ Reverts the series for particular naming series:
+ * key is naming series - SINV-.YYYY-.####
+ * name is actual name - SINV-2021-0001
+
+ 1. This function split the key into two parts prefix (SINV-YYYY) & hashes (####).
+ 2. Use prefix to get the current index of that naming series from Series table
+ 3. Then revert the current index.
+
+ *For custom naming series:*
+ 1. hash can exist anywhere, if it exist in hashes then it take normal flow.
+ 2. If hash doesn't exit in hashes, we get the hash from prefix, then update name and prefix accordingly.
+
+ *Example:*
+ 1. key = SINV-.YYYY.-
+ * If key doesn't have hash it will add hash at the end
+ * prefix will be SINV-YYYY based on this will get current index from Series table.
+ 2. key = SINV-.####.-2021
+ * now prefix = SINV-#### and hashes = 2021 (hash doesn't exist)
+ * will search hash in key then accordingly get prefix = SINV-
+ 3. key = ####.-2021
+ * prefix = #### and hashes = 2021 (hash doesn't exist)
+ * will search hash in key then accordingly get prefix = ""
+ """
+ if ".#" in key:
prefix, hashes = key.rsplit(".", 1)
if "#" not in hashes:
- return
+ # get the hash part from the key
+ hash = re.search("#+", key)
+ if not hash:
+ return
+ name = name.replace(hashes, "")
+ prefix = prefix.replace(hash.group(), "")
else:
prefix = key
diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js
index 207483a164..bc9b82404f 100644
--- a/frappe/public/js/frappe/desk.js
+++ b/frappe/public/js/frappe/desk.js
@@ -473,15 +473,20 @@ frappe.Application = class Application {
$('').appendTo("head");
$('').appendTo("head");
}
- trigger_primary_action() {
- if(window.cur_dialog && cur_dialog.display) {
- // trigger primary
- cur_dialog.get_primary_btn().trigger("click");
- } else if(cur_frm && cur_frm.page.btn_primary.is(':visible')) {
- cur_frm.page.btn_primary.trigger('click');
- } else if(frappe.container.page.save_action) {
- frappe.container.page.save_action();
- }
+ trigger_primary_action: function() {
+ // to trigger change event on active input before triggering primary action
+ $(document.activeElement).blur();
+ // wait for possible JS validations triggered after blur (it might change primary button)
+ setTimeout(() => {
+ if (window.cur_dialog && cur_dialog.display) {
+ // trigger primary
+ cur_dialog.get_primary_btn().trigger("click");
+ } else if (cur_frm && cur_frm.page.btn_primary.is(':visible')) {
+ cur_frm.page.btn_primary.trigger('click');
+ } else if (frappe.container.page.save_action) {
+ frappe.container.page.save_action();
+ }
+ }, 100);
}
set_rtl() {
diff --git a/frappe/public/js/frappe/form/grid_row.js b/frappe/public/js/frappe/form/grid_row.js
index f6da88df57..453b8b5f24 100644
--- a/frappe/public/js/frappe/form/grid_row.js
+++ b/frappe/public/js/frappe/form/grid_row.js
@@ -7,7 +7,8 @@ export default class GridRow {
$.extend(this, opts);
if (this.doc && this.parent_df.options) {
frappe.meta.make_docfield_copy_for(this.parent_df.options, this.doc.name, this.docfields);
- this.docfields = frappe.meta.get_docfields(this.parent_df.options, this.doc.name);
+ const docfields = frappe.meta.get_docfields(this.parent_df.options, this.doc.name);
+ this.docfields = docfields.length ? docfields : opts.docfields;
}
this.columns = {};
this.columns_list = [];
diff --git a/frappe/tests/test_boilerplate.py b/frappe/tests/test_boilerplate.py
new file mode 100644
index 0000000000..4ae78c94de
--- /dev/null
+++ b/frappe/tests/test_boilerplate.py
@@ -0,0 +1,88 @@
+import ast
+import glob
+import os
+import shutil
+import unittest
+from unittest.mock import patch
+
+import frappe
+from frappe.utils.boilerplate import make_boilerplate
+
+
+class TestBoilerPlate(unittest.TestCase):
+ @classmethod
+ def tearDownClass(cls):
+
+ bench_path = frappe.utils.get_bench_path()
+ test_app_dir = os.path.join(bench_path, "apps", "test_app")
+ if os.path.exists(test_app_dir):
+ shutil.rmtree(test_app_dir)
+
+ def test_create_app(self):
+ title = "Test App"
+ description = "Test app for unit testing"
+ publisher = "Test Publisher"
+ email = "example@example.org"
+ icon = "" # empty -> default
+ color = ""
+ app_license = "MIT"
+
+ user_input = [
+ title,
+ description,
+ publisher,
+ email,
+ icon,
+ color,
+ app_license,
+ ]
+
+ bench_path = frappe.utils.get_bench_path()
+ apps_dir = os.path.join(bench_path, "apps")
+ app_name = "test_app"
+
+ with patch("builtins.input", side_effect=user_input):
+ make_boilerplate(apps_dir, app_name)
+
+ root_paths = [
+ app_name,
+ "requirements.txt",
+ "README.md",
+ "setup.py",
+ "license.txt",
+ ".git",
+ ]
+ paths_inside_app = [
+ "__init__.py",
+ "hooks.py",
+ "patches.txt",
+ "templates",
+ "www",
+ "config",
+ "modules.txt",
+ "public",
+ app_name,
+ ]
+
+ new_app_dir = os.path.join(bench_path, apps_dir, app_name)
+
+ all_paths = list()
+
+ for path in root_paths:
+ all_paths.append(os.path.join(new_app_dir, path))
+
+ for path in paths_inside_app:
+ all_paths.append(os.path.join(new_app_dir, app_name, path))
+
+ for path in all_paths:
+ self.assertTrue(os.path.exists(path), msg=f"{path} should exist in new app")
+
+ # check if python files are parsable
+ python_files = glob.glob(new_app_dir + "**/*.py", recursive=True)
+
+ for python_file in python_files:
+ with open(python_file) as p:
+ try:
+ ast.parse(p.read())
+ except Exception as e:
+ self.fail(f"Can't parse python file in new app: {python_file}\n" + str(e))
diff --git a/frappe/tests/test_naming.py b/frappe/tests/test_naming.py
index b47fb809ca..66d48e3612 100644
--- a/frappe/tests/test_naming.py
+++ b/frappe/tests/test_naming.py
@@ -70,9 +70,9 @@ class TestNaming(unittest.TestCase):
name = 'TEST-{}-00001'.format(year)
frappe.db.sql("""INSERT INTO `tabSeries` (name, current) values (%s, 1)""", (series,))
revert_series_if_last(key, name)
- count = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0]
+ current_index = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0]
- self.assertEqual(count.get('current'), 0)
+ self.assertEqual(current_index.get('current'), 0)
frappe.db.sql("""delete from `tabSeries` where name = %s""", series)
series = 'TEST-{}-'.format(year)
@@ -80,9 +80,9 @@ class TestNaming(unittest.TestCase):
name = 'TEST-{}-00002'.format(year)
frappe.db.sql("""INSERT INTO `tabSeries` (name, current) values (%s, 2)""", (series,))
revert_series_if_last(key, name)
- count = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0]
+ current_index = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0]
- self.assertEqual(count.get('current'), 1)
+ self.assertEqual(current_index.get('current'), 1)
frappe.db.sql("""delete from `tabSeries` where name = %s""", series)
series = 'TEST-'
@@ -91,7 +91,29 @@ class TestNaming(unittest.TestCase):
frappe.db.sql("DELETE FROM `tabSeries` WHERE `name`=%s", series)
frappe.db.sql("""INSERT INTO `tabSeries` (name, current) values (%s, 3)""", (series,))
revert_series_if_last(key, name)
- count = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0]
+ current_index = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0]
- self.assertEqual(count.get('current'), 2)
+ self.assertEqual(current_index.get('current'), 2)
+ frappe.db.sql("""delete from `tabSeries` where name = %s""", series)
+
+ series = 'TEST1-'
+ key = 'TEST1-.#####.-2021-22'
+ name = 'TEST1-00003-2021-22'
+ frappe.db.sql("DELETE FROM `tabSeries` WHERE `name`=%s", series)
+ frappe.db.sql("""INSERT INTO `tabSeries` (name, current) values (%s, 3)""", (series,))
+ revert_series_if_last(key, name)
+ current_index = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0]
+
+ self.assertEqual(current_index.get('current'), 2)
+ frappe.db.sql("""delete from `tabSeries` where name = %s""", series)
+
+ series = ''
+ key = '.#####.-2021-22'
+ name = '00003-2021-22'
+ frappe.db.sql("DELETE FROM `tabSeries` WHERE `name`=%s", series)
+ frappe.db.sql("""INSERT INTO `tabSeries` (name, current) values (%s, 3)""", (series,))
+ revert_series_if_last(key, name)
+ current_index = frappe.db.sql("""SELECT current from `tabSeries` where name = %s""", series, as_dict=True)[0]
+
+ self.assertEqual(current_index.get('current'), 2)
frappe.db.sql("""delete from `tabSeries` where name = %s""", series)
diff --git a/frappe/translate.py b/frappe/translate.py
index aeca758a9d..1d8b1234c7 100644
--- a/frappe/translate.py
+++ b/frappe/translate.py
@@ -98,6 +98,7 @@ def get_dict(fortype, name=None):
translation_assets = cache.hget("translation_assets", frappe.local.lang, shared=True) or {}
if not asset_key in translation_assets:
+ messages = []
if fortype=="doctype":
messages = get_messages_from_doctype(name)
elif fortype=="page":
@@ -109,14 +110,12 @@ def get_dict(fortype, name=None):
elif fortype=="jsfile":
messages = get_messages_from_file(name)
elif fortype=="boot":
- messages = []
apps = frappe.get_all_apps(True)
for app in apps:
messages.extend(get_server_messages(app))
- messages = deduplicate_messages(messages)
- messages += frappe.db.sql("""select 'navbar', item_label from `tabNavbar Item` where item_label is not null""")
- messages = get_messages_from_include_files()
+ messages += get_messages_from_navbar()
+ messages += get_messages_from_include_files()
messages += frappe.db.sql("select 'Print Format:', name from `tabPrint Format`")
messages += frappe.db.sql("select 'DocType:', name from tabDocType")
messages += frappe.db.sql("select 'Role:', name from tabRole")
@@ -124,6 +123,7 @@ def get_dict(fortype, name=None):
messages += frappe.db.sql("select '', format from `tabWorkspace Shortcut` where format is not null")
messages += frappe.db.sql("select '', title from `tabOnboarding Step`")
+ messages = deduplicate_messages(messages)
message_dict = make_dict_from_messages(messages, load_user_translation=False)
message_dict.update(get_dict_from_hooks(fortype, name))
# remove untranslated
@@ -320,10 +320,22 @@ def get_messages_for_app(app, deduplicate=True):
# server_messages
messages.extend(get_server_messages(app))
+
+ # messages from navbar settings
+ messages.extend(get_messages_from_navbar())
+
if deduplicate:
messages = deduplicate_messages(messages)
+
return messages
+
+def get_messages_from_navbar():
+ """Return all labels from Navbar Items, as specified in Navbar Settings."""
+ labels = frappe.get_all('Navbar Item', filters={'item_label': ('is', 'set')}, pluck='item_label')
+ return [('Navbar:', label, 'Label of a Navbar Item') for label in labels]
+
+
def get_messages_from_doctype(name):
"""Extract all translatable messages for a doctype. Includes labels, Python code,
Javascript code, html templates"""
@@ -490,8 +502,14 @@ def get_server_messages(app):
def get_messages_from_include_files(app_name=None):
"""Returns messages from js files included at time of boot like desk.min.js for desk and web"""
messages = []
- for file in (frappe.get_hooks("app_include_js", app_name=app_name) or []) + (frappe.get_hooks("web_include_js", app_name=app_name) or []):
- messages.extend(get_messages_from_file(os.path.join(frappe.local.sites_path, file)))
+ app_include_js = frappe.get_hooks("app_include_js", app_name=app_name) or []
+ web_include_js = frappe.get_hooks("web_include_js", app_name=app_name) or []
+ include_js = app_include_js + web_include_js
+
+ for js_path in include_js:
+ relative_path = os.path.join(frappe.local.sites_path, js_path.lstrip('/'))
+ messages_from_file = get_messages_from_file(relative_path)
+ messages.extend(messages_from_file)
return messages
diff --git a/frappe/utils/boilerplate.py b/frappe/utils/boilerplate.py
index f6a2ac488c..20e98ac67f 100755
--- a/frappe/utils/boilerplate.py
+++ b/frappe/utils/boilerplate.py
@@ -3,8 +3,6 @@
from __future__ import unicode_literals, print_function
-from six.moves import input
-
import frappe, os, re, git
from frappe.utils import touch_file, cstr
diff --git a/frappe/website/doctype/web_template/web_template.py b/frappe/website/doctype/web_template/web_template.py
index 3c61807099..2fd5bfa179 100644
--- a/frappe/website/doctype/web_template/web_template.py
+++ b/frappe/website/doctype/web_template/web_template.py
@@ -9,6 +9,7 @@ from shutil import rmtree
import frappe
from frappe.model.document import Document
+from frappe.website.render import clear_cache
from frappe import _
from frappe.modules.export_file import (
write_document_file,
@@ -37,6 +38,19 @@ class WebTemplate(Document):
if was_standard and not self.standard:
self.import_from_files()
+ def on_update(self):
+ """Clear cache for all Web Pages in which this template is used"""
+ routes = frappe.db.get_all(
+ "Web Page",
+ filters=[
+ ["Web Page Block", "web_template", "=", self.name],
+ ["Web Page", "published", "=", 1],
+ ],
+ pluck="route",
+ )
+ for route in routes:
+ clear_cache(route)
+
def on_trash(self):
if frappe.conf.developer_mode and self.standard:
# delete template html and json files