diff --git a/.mergify.yml b/.mergify.yml
index 63fe1a0086..838ce75835 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -5,6 +5,7 @@ pull_request_rules:
- and:
- author!=surajshetty3416
- author!=gavindsouza
+ - author!=deepeshgarg007
- or:
- base=version-13
- base=version-12
diff --git a/cypress/integration/control_dynamic_link.js b/cypress/integration/control_dynamic_link.js
new file mode 100644
index 0000000000..cc1eb0b695
--- /dev/null
+++ b/cypress/integration/control_dynamic_link.js
@@ -0,0 +1,128 @@
+context('Dynamic Link', () => {
+ before(() => {
+ cy.login();
+ cy.visit('/app/doctype');
+ return cy.window().its('frappe').then(frappe => {
+ return frappe.xcall('frappe.tests.ui_test_helpers.create_doctype', {
+ name: 'Test Dynamic Link',
+ fields: [
+ {
+ "label": "Document Type",
+ "fieldname": "doc_type",
+ "fieldtype": "Link",
+ "options": "DocType",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ },
+ {
+ "label": "Document ID",
+ "fieldname": "doc_id",
+ "fieldtype": "Dynamic Link",
+ "options": "doc_type",
+ "in_list_view": 1,
+ "in_standard_filter": 1,
+ },
+ ]
+ });
+ });
+ });
+
+
+ function get_dialog_with_dynamic_link() {
+ return cy.dialog({
+ title: 'Dynamic Link',
+ fields: [{
+ "label": "Document Type",
+ "fieldname": "doc_type",
+ "fieldtype": "Link",
+ "options": "DocType",
+ "in_list_view": 1,
+ },
+ {
+ "label": "Document ID",
+ "fieldname": "doc_id",
+ "fieldtype": "Dynamic Link",
+ "options": "doc_type",
+ "in_list_view": 1,
+ }]
+ });
+ }
+
+ function get_dialog_with_dynamic_link_option() {
+ return cy.dialog({
+ title: 'Dynamic Link',
+ fields: [{
+ "label": "Document Type",
+ "fieldname": "doc_type",
+ "fieldtype": "Link",
+ "options": "DocType",
+ "in_list_view": 1,
+ },
+ {
+ "label": "Document ID",
+ "fieldname": "doc_id",
+ "fieldtype": "Dynamic Link",
+ "get_options": () => {
+ return "User";
+ },
+ "in_list_view": 1,
+ }]
+ });
+ }
+
+ it('Creating a dynamic link by passing option as function and verifying it in a dialog', () => {
+ get_dialog_with_dynamic_link_option().as('dialog');
+ cy.get_field('doc_type').clear();
+ cy.fill_field('doc_type', 'User', 'Link');
+ cy.get_field('doc_id').click();
+
+ //Checking if the listbox have length greater than 0
+ cy.get('[data-fieldname="doc_id"]').find('.awesomplete').find("li").its('length').should('be.gte', 0);
+ cy.get('.btn-modal-close').click({force: true});
+ });
+
+ it('Creating a dynamic link and verifying it in a dialog', () => {
+ get_dialog_with_dynamic_link().as('dialog');
+ cy.get_field('doc_type').clear();
+ cy.fill_field('doc_type', 'User', 'Link');
+ cy.get_field('doc_id').click();
+
+ //Checking if the listbox have length greater than 0
+ cy.get('[data-fieldname="doc_id"]').find('.awesomplete').find("li").its('length').should('be.gte', 0);
+ cy.get('.btn-modal-close').click({force: true, multiple: true});
+ });
+
+ it('Creating a dynamic link and verifying it', () => {
+ cy.visit('/app/test-dynamic-link');
+
+ //Clicking on the Document ID field
+ cy.get_field('doc_type').clear();
+
+ //Entering User in the Doctype field
+ cy.fill_field('doc_type', 'User', 'Link', {delay: 500});
+ cy.get_field('doc_id').click();
+
+ //Checking if the listbox have length greater than 0
+ cy.get('[data-fieldname="doc_id"]').find('.awesomplete').find("li").its('length').should('be.gte', 0);
+
+ //Opening a new form for dynamic link doctype
+ cy.new_form('Test Dynamic Link');
+ cy.get_field('doc_type').clear();
+
+ //Entering User in the Doctype field
+ cy.fill_field('doc_type', 'User', 'Link', {delay: 500});
+ cy.get_field('doc_id').click();
+
+ //Checking if the listbox have length greater than 0
+ cy.get('[data-fieldname="doc_id"]').find('.awesomplete').find("li").its('length').should('be.gte', 0);
+ cy.get_field('doc_type').clear();
+
+ //Entering System Settings in the Doctype field
+ cy.fill_field('doc_type', 'System Settings', 'Link', {delay: 500});
+ cy.get_field('doc_id').click();
+
+ //Checking if the system throws error
+ cy.get('.modal-title').should('have.text', 'Error');
+ cy.get('.msgprint').should('have.text', 'System Settings is not a valid DocType for Dynamic Link');
+ });
+});
\ No newline at end of file
diff --git a/cypress/support/index.js b/cypress/support/index.js
index 9cd770a31e..5980e96677 100644
--- a/cypress/support/index.js
+++ b/cypress/support/index.js
@@ -17,6 +17,9 @@
import './commands';
import '@cypress/code-coverage/support';
+Cypress.on('uncaught:exception', (err, runnable) => {
+ return false;
+});
// Alternatively you can use CommonJS syntax:
// require('./commands')
diff --git a/frappe/__init__.py b/frappe/__init__.py
index 0abaf932a7..0eca4d99d0 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# License: MIT. See LICENSE
"""
Frappe - Low Code Open Source Framework in Python and JS
@@ -20,10 +20,10 @@ if _dev_server:
warnings.simplefilter('always', DeprecationWarning)
warnings.simplefilter('always', PendingDeprecationWarning)
-from werkzeug.local import Local, release_local
import sys, importlib, inspect, json
-import typing
import click
+from werkzeug.local import Local, release_local
+from typing import TYPE_CHECKING, Dict, List, Union
# Local application imports
from .exceptions import *
@@ -143,15 +143,14 @@ lang = local("lang")
# This if block is never executed when running the code. It is only used for
# telling static code analyzer where to find dynamically defined attributes.
-if typing.TYPE_CHECKING:
- from frappe.utils.redis_wrapper import RedisWrapper
-
+if TYPE_CHECKING:
from frappe.database.mariadb.database import MariaDBDatabase
from frappe.database.postgres.database import PostgresDatabase
from frappe.query_builder.builder import MariaDB, Postgres
+ from frappe.utils.redis_wrapper import RedisWrapper
- db: typing.Union[MariaDBDatabase, PostgresDatabase]
- qb: typing.Union[MariaDB, Postgres]
+ db: Union[MariaDBDatabase, PostgresDatabase]
+ qb: Union[MariaDB, Postgres]
# end: static analysis hack
@@ -897,7 +896,12 @@ def clear_document_cache(doctype, name):
cache().hdel('document_cache', key)
def get_cached_value(doctype, name, fieldname, as_dict=False):
- doc = get_cached_doc(doctype, name)
+ try:
+ doc = get_cached_doc(doctype, name)
+ except DoesNotExistError:
+ clear_last_message()
+ return
+
if isinstance(fieldname, str):
if as_dict:
throw('Cannot make dict for single fieldname')
@@ -1523,12 +1527,16 @@ def get_value(*args, **kwargs):
"""
return db.get_value(*args, **kwargs)
-def as_json(obj, indent=1):
+def as_json(obj: Union[Dict, List], indent=1) -> str:
from frappe.utils.response import json_handler
+
try:
return json.dumps(obj, indent=indent, sort_keys=True, default=json_handler, separators=(',', ': '))
except TypeError:
- return json.dumps(obj, indent=indent, default=json_handler, separators=(',', ': '))
+ # 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=(',', ': '))
def are_emails_muted():
from frappe.utils import cint
diff --git a/frappe/database/database.py b/frappe/database/database.py
index 24dfdd32df..511d993aa5 100644
--- a/frappe/database/database.py
+++ b/frappe/database/database.py
@@ -119,8 +119,8 @@ class Database(object):
if not run:
return query
- # remove \n \t from start and end of query
- query = re.sub(r'^\s*|\s*$', '', query)
+ # remove whitespace / indentation from start and end of query
+ query = query.strip()
if re.search(r'ifnull\(', query, flags=re.IGNORECASE):
# replaces ifnull in query with coalesce
@@ -357,6 +357,7 @@ class Database(object):
order_by="KEEP_DEFAULT_ORDERING",
cache=False,
for_update=False,
+ *,
run=True,
pluck=False,
distinct=False,
@@ -386,17 +387,27 @@ class Database(object):
frappe.db.get_value("System Settings", None, "date_format")
"""
- ret = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug,
+ result = self.get_values(doctype, filters, fieldname, ignore, as_dict, debug,
order_by, cache=cache, for_update=for_update, run=run, pluck=pluck, distinct=distinct, limit=1)
if not run:
- return ret
+ return result
+
+ if not result:
+ return None
+
+ row = result[0]
+
+ if len(row) > 1 or as_dict:
+ return row
+ else:
+ # single field is requested, send it without wrapping in containers
+ return row[0]
- return ((len(ret[0]) > 1 or as_dict) and ret[0] or ret[0][0]) if ret else None
def get_values(self, doctype, filters=None, fieldname="name", ignore=None, as_dict=False,
debug=False, order_by="KEEP_DEFAULT_ORDERING", update=None, cache=False, for_update=False,
- run=True, pluck=False, distinct=False, limit=None):
+ *, run=True, pluck=False, distinct=False, limit=None):
"""Returns multiple document properties.
:param doctype: DocType name.
@@ -487,6 +498,7 @@ class Database(object):
as_dict=False,
debug=False,
update=None,
+ *,
run=True,
pluck=False,
distinct=False,
@@ -621,7 +633,8 @@ class Database(object):
filters,
doctype,
as_dict,
- debug,
+ *,
+ debug=False,
order_by=None,
update=None,
for_update=False,
@@ -661,7 +674,7 @@ class Database(object):
)
return r
- def _get_value_for_many_names(self, doctype, names, field, order_by, debug=False, run=True, pluck=False, distinct=False, limit=None):
+ def _get_value_for_many_names(self, doctype, names, field, order_by, *, debug=False, run=True, pluck=False, distinct=False, limit=None):
names = list(filter(None, names))
if names:
return self.get_all(
diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js
index 5d04fbe982..267419a887 100644
--- a/frappe/printing/page/print/print.js
+++ b/frappe/printing/page/print/print.js
@@ -37,7 +37,7 @@ frappe.ui.form.PrintView = class {
this.print_wrapper = this.page.main.empty().html(
`
${frappe.render_template('print_skeleton_loading')}
-
diff --git a/frappe/public/js/frappe/ui/theme_switcher.js b/frappe/public/js/frappe/ui/theme_switcher.js
index 2c1d93a2ec..16ba16e5d5 100644
--- a/frappe/public/js/frappe/ui/theme_switcher.js
+++ b/frappe/public/js/frappe/ui/theme_switcher.js
@@ -124,7 +124,7 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
toggle_theme(theme) {
this.current_theme = theme.toLowerCase();
document.documentElement.setAttribute("data-theme-mode", this.current_theme);
- frappe.show_alert("Theme Changed", 3);
+ frappe.show_alert(__("Theme Changed"), 3);
frappe.xcall("frappe.core.doctype.user.user.switch_theme", {
theme: toTitle(theme)
diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py
index 10c601db00..33a5006161 100644
--- a/frappe/tests/test_db.py
+++ b/frappe/tests/test_db.py
@@ -312,6 +312,21 @@ class TestDB(unittest.TestCase):
frappe.db.MAX_WRITES_PER_TRANSACTION = Database.MAX_WRITES_PER_TRANSACTION
+ def test_transaction_write_counting(self):
+ note = frappe.get_doc(doctype="Note", title="transaction counting").insert()
+
+ writes = frappe.db.transaction_writes
+ frappe.db.set_value("Note", note.name, "content", "abc")
+ self.assertEqual(1, frappe.db.transaction_writes - writes)
+ writes = frappe.db.transaction_writes
+
+ frappe.db.sql("""
+ update `tabNote`
+ set content = 'abc'
+ where name = %s
+ """, note.name)
+ self.assertEqual(1, frappe.db.transaction_writes - writes)
+
def test_pk_collision_ignoring(self):
# note has `name` generated from title
for _ in range(3):
diff --git a/frappe/tests/test_translate.py b/frappe/tests/test_translate.py
index 1b96fb62c3..f5386914f7 100644
--- a/frappe/tests/test_translate.py
+++ b/frappe/tests/test_translate.py
@@ -36,7 +36,18 @@ class TestTranslate(unittest.TestCase):
def test_extract_message_from_file(self):
data = frappe.translate.get_messages_from_file(translation_string_file)
- self.assertListEqual(data, expected_output)
+ exp_filename = "apps/frappe/frappe/tests/translation_test_file.txt"
+
+ self.assertEqual(len(data), len(expected_output),
+ msg=f"Mismatched output:\nExpected: {expected_output}\nFound: {data}")
+
+ for extracted, expected in zip(data, expected_output):
+ ext_filename, ext_message, ext_context, ext_line = extracted
+ exp_message, exp_context, exp_line = expected
+ self.assertEqual(ext_filename, exp_filename)
+ self.assertEqual(ext_message, exp_message)
+ self.assertEqual(ext_context, exp_context)
+ self.assertEqual(ext_line, exp_line)
def test_translation_with_context(self):
try:
@@ -107,13 +118,16 @@ class TestTranslate(unittest.TestCase):
expected_output = [
- ('apps/frappe/frappe/tests/translation_test_file.txt', 'Warning: Unable to find {0} in any table related to {1}', 'This is some context', 2),
- ('apps/frappe/frappe/tests/translation_test_file.txt', 'Warning: Unable to find {0} in any table related to {1}', None, 4),
- ('apps/frappe/frappe/tests/translation_test_file.txt', "You don't have any messages yet.", None, 6),
- ('apps/frappe/frappe/tests/translation_test_file.txt', 'Submit', 'Some DocType', 8),
- ('apps/frappe/frappe/tests/translation_test_file.txt', 'Warning: Unable to find {0} in any table related to {1}', 'This is some context', 15),
- ('apps/frappe/frappe/tests/translation_test_file.txt', 'Submit', 'Some DocType', 17),
- ('apps/frappe/frappe/tests/translation_test_file.txt', "You don't have any messages yet.", None, 19),
- ('apps/frappe/frappe/tests/translation_test_file.txt', "You don't have any messages yet.", None, 21)
+ ('Warning: Unable to find {0} in any table related to {1}', 'This is some context', 2),
+ ('Warning: Unable to find {0} in any table related to {1}', None, 4),
+ ("You don't have any messages yet.", None, 6),
+ ('Submit', 'Some DocType', 8),
+ ('Warning: Unable to find {0} in any table related to {1}', 'This is some context', 15),
+ ('Submit', 'Some DocType', 17),
+ ("You don't have any messages yet.", None, 19),
+ ("You don't have any messages yet.", None, 21),
+ ("Long string that needs its own line because of black formatting.", None, 24),
+ ("Long string with", "context", 28),
+ ("Long string with", "context on newline", 32),
]
diff --git a/frappe/tests/translation_test_file.txt b/frappe/tests/translation_test_file.txt
index 45f67a806b..7db71665ad 100644
--- a/frappe/tests/translation_test_file.txt
+++ b/frappe/tests/translation_test_file.txt
@@ -18,4 +18,18 @@ _('Submit', context="Some DocType")
_("""You don't have any messages yet.""")
-_('''You don't have any messages yet.''')
\ No newline at end of file
+_('''You don't have any messages yet.''')
+
+// allow newline in beginning
+_(
+"""Long string that needs its own line because of black formatting."""
+).format("blah")
+
+_(
+"Long string with", context="context"
+).format("blah")
+
+_(
+ "Long string with",
+ context="context on newline"
+).format("blah")
diff --git a/frappe/translate.py b/frappe/translate.py
index 0367d33d3b..6e0fefd6fa 100644
--- a/frappe/translate.py
+++ b/frappe/translate.py
@@ -23,6 +23,35 @@ from frappe.utils import get_bench_path, is_html, strip, strip_html_tags
from frappe.query_builder import Field, DocType
from pypika.terms import PseudoColumn
+TRANSLATE_PATTERN = re.compile(
+ r"_\([\s\n]*" # starts with literal `_(`, ignore following whitespace/newlines
+
+ # BEGIN: message search
+ r"([\"']{,3})" # start of message string identifier - allows: ', ", """, '''; 1st capture group
+ r"(?P
((?!\1).)*)" # Keep matching until string closing identifier is met which is same as 1st capture group
+ r"\1" # match exact string closing identifier
+ # END: message search
+
+ # BEGIN: python context search
+ r"([\s\n]*,[\s\n]*context\s*=\s*" # capture `context=` with ignoring whitespace
+ r"([\"'])" # start of context string identifier; 5th capture group
+ r"(?P((?!\5).)*)" # capture context string till closing id is found
+ r"\5" # match context string closure
+ r")?" # match 0 or 1 context strings
+ # END: python context search
+
+ # BEGIN: JS context search
+ r"(\s*,\s*(.)*?\s*(,\s*" # skip message format replacements: ["format", ...] | null | []
+ r"([\"'])" # start of context string; 11th capture group
+ r"(?P((?!\11).)*)" # capture context string till closing id is found
+ r"\11" # match context string closure
+ r")*"
+ r")*" # match one or more context string
+ # END: JS context search
+
+ r"[\s\n]*\)" # Closing function call ignore leading whitespace/newlines
+)
+
def get_language(lang_list: List = None) -> str:
"""Set `frappe.local.lang` from HTTP headers at beginning of request
@@ -651,9 +680,8 @@ def extract_messages_from_code(code):
frappe.clear_last_message()
messages = []
- pattern = r"_\(([\"']{,3})(?P((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P((?!\5).)*)\5)*(\s*,\s*(.)*?\s*(,\s*([\"'])(?P((?!\11).)*)\11)*)*\)"
- for m in re.compile(pattern).finditer(code):
+ for m in TRANSLATE_PATTERN.finditer(code):
message = m.group('message')
context = m.group('py_context') or m.group('js_context')
pos = m.start()
diff --git a/frappe/translations/fr.csv b/frappe/translations/fr.csv
index 511c590a59..5d08e2ae24 100644
--- a/frappe/translations/fr.csv
+++ b/frappe/translations/fr.csv
@@ -218,7 +218,7 @@ Route,Route,
Sales Manager,Responsable des Ventes,
Sales Master Manager,Directeur des Ventes,
Sales User,Chargé de Ventes,
-Salutation,Salutations,
+Salutation,Civilité,
Sample,Échantillon,
Saturday,Samedi,
Saved,Enregistré,
@@ -786,6 +786,7 @@ Custom Sidebar Menu,Barre Latérale Personnalisée,
Custom Translations,Traductions Personnalisées,
Customization,Personnalisation,
Customizations Reset,Réinitialiser les Personnalisations,
+Reset Customizations,Réinitialiser les Personnalisations,
Customizations for {0} exported to:
{1},Personnalisations pour {0} exportées vers:
{1},
Customize Form,Personnaliser le formulaire,
Customize Form Field,Personnaliser un Champ de Formulaire,
@@ -1508,7 +1509,7 @@ Login not allowed at this time,Connexion non autorisée pour le moment,
Login to comment,Connectez-vous pour commenter,
Login token required,Identifiants de Connexion Requis,
Login with LDAP,Se connecter avec LDAP,
-Logout,Connectez - Out,
+Logout,Déconnecté,
Long Text,Texte Long,
Looks like something is wrong with this site's Paypal configuration.,Il semble qu'il y ait une erreur avec la configuration Paypal de ce site.,
Looks like something is wrong with this site's payment gateway configuration. No payment has been made.,On dirait que quelque chose ne va pas dans la configuration de la passerelle de paiement de ce site. Aucun paiement n'a été effectué.,
@@ -2164,7 +2165,7 @@ Search for '{0}',Rechercher '{0}',
Search for anything,Rechercher tout,
Search in a document type,Rechercher dans un type de document,
Search or Create a New Chat,Rechercher ou créer un nouveau chat,
-Search or type a command,Rechercher ou taper une commande,
+Search or type a command (Ctrl + G),Rechercher ou taper une commande (Ctrl + G),
Search...,Rechercher...,
Searching,Recherche,
Searching ...,Recherche ...,
@@ -4139,7 +4140,7 @@ Document is only editable by users with role,Le document n'est modifiable qu
{0}: Other permission rules may also apply,{0}: d'autres règles d'autorisation peuvent également s'appliquer,
{0} Page Views,{0} pages vues,
Expand,Développer,
-Collapse,Effondrer,
+Collapse,Réduire,
"Invalid Bearer token, please provide a valid access token with prefix 'Bearer'.","Jeton de porteur non valide, veuillez fournir un jeton d'accès valide avec le préfixe «porteur».",
"Failed to decode token, please provide a valid base64-encoded token.","Échec du décodage du jeton, veuillez fournir un jeton encodé en base64 valide.",
"Invalid token, please provide a valid token with prefix 'Basic' or 'Token'.","Jeton non valide, veuillez fournir un jeton valide avec le préfixe «Basic» ou «Token».",
@@ -4215,7 +4216,7 @@ since yesterday,depuis hier,
since last week,depuis la semaine dernière,
since last month,depuis le mois dernier,
since last year,depuis l'année dernière,
-Show,Spectacle,
+Show,Afficher,
New Number Card,Nouvelle carte de numéro,
Your Shortcuts,Vos raccourcis,
You haven't added any Dashboard Charts or Number Cards yet.,Vous n'avez pas encore ajouté de tableaux de bord ou de cartes numériques.,
@@ -4700,3 +4701,16 @@ Value cannot be negative for {0}: {1},La valeur ne peut pas être négative pour
Negative Value,Valeur négative,
Authentication failed while receiving emails from Email Account: {0}.,L'authentification a échoué lors de la réception des e-mails du compte de messagerie: {0}.,
Message from server: {0},Message du serveur: {0},
+{0} edited this {1},{0} a édité {1},
+{0} created this {1}, {0} a créé {1}
+Report an Issue, Signaler une anomalie
+About, A Propos
+My Profile, Mon profil
+My Settings, Mes paramètres
+Toggle Full Width, Changer l'affichage en pleine largeur
+Toggle Theme, Basculer le thème
+Theme Changed, Thème changé
+Amend, Nouv. version
+Document has been submitted, Document validé
+Document has been cancelled, Document annulé
+Document is in draft state, Document au statut brouillon
diff --git a/frappe/utils/data.py b/frappe/utils/data.py
index 212ae8eba6..e9f029d293 100644
--- a/frappe/utils/data.py
+++ b/frappe/utils/data.py
@@ -1645,18 +1645,21 @@ def validate_json_string(string: str) -> None:
raise frappe.ValidationError
def get_user_info_for_avatar(user_id: str) -> Dict:
- user_info = {
- "email": user_id,
- "image": "",
- "name": user_id
- }
try:
- user_info["email"] = frappe.get_cached_value("User", user_id, "email")
- user_info["name"] = frappe.get_cached_value("User", user_id, "full_name")
- user_info["image"] = frappe.get_cached_value("User", user_id, "user_image")
- except Exception:
- frappe.local.message_log = []
- return user_info
+ user = frappe.get_cached_doc("User", user_id)
+ return {
+ "email": user.email,
+ "image": user.user_image,
+ "name": user.full_name
+ }
+
+ except frappe.DoesNotExistError:
+ frappe.clear_last_message()
+ return {
+ "email": user_id,
+ "image": "",
+ "name": user_id
+ }
def validate_python_code(string: str, fieldname=None, is_expression: bool = True) -> None:
diff --git a/frappe/utils/jinja.py b/frappe/utils/jinja.py
index 3702a009fb..4dd297b5b3 100644
--- a/frappe/utils/jinja.py
+++ b/frappe/utils/jinja.py
@@ -48,7 +48,8 @@ def validate_template(html):
"""Throws exception if there is a syntax error in the Jinja Template"""
import frappe
from jinja2 import TemplateSyntaxError
-
+ if not html:
+ return
jenv = get_jenv()
try:
jenv.from_string(html)