diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml
new file mode 100644
index 0000000000..321dfb567b
--- /dev/null
+++ b/.github/workflows/semgrep.yml
@@ -0,0 +1,22 @@
+name: Semgrep
+
+on:
+ pull_request:
+ branches:
+ - develop
+jobs:
+ semgrep:
+ name: Frappe Linter
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: Setup python3
+ uses: actions/setup-python@v2
+ with:
+ python-version: 3.8
+ - name: Run semgrep
+ run: |
+ python -m pip install -q semgrep
+ git fetch origin $GITHUB_BASE_REF:$GITHUB_BASE_REF -q
+ files=$(git diff --name-only --diff-filter=d $GITHUB_BASE_REF)
+ if [ -f .semgrep.yml ]; then semgrep --config=.semgrep.yml --quiet --error $files; fi
diff --git a/.semgrep.yml b/.semgrep.yml
new file mode 100644
index 0000000000..99d237251e
--- /dev/null
+++ b/.semgrep.yml
@@ -0,0 +1,29 @@
+#Reference: https://semgrep.dev/docs/writing-rules/rule-syntax/
+
+rules:
+- id: eval
+ patterns:
+ - pattern-not: eval("...")
+ - pattern: eval(...)
+ message: |
+ Detected the use of eval(). eval() can be dangerous if used to evaluate
+ dynamic content. Avoid it or use safe_eval().
+ languages:
+ - python
+ severity: ERROR
+
+# translations
+- id: frappe-translation-syntax-python
+ pattern-either:
+ - pattern: _(f"...") # f-strings not allowed
+ - pattern: _("..." + "...") # concatenation not allowed
+ - pattern: _("") # empty string is meaningless
+ - pattern: _("..." % ...) # Only positional formatters are allowed.
+ - pattern: _("...".format(...)) # format should not be used before translating
+ - pattern: _("...") + ... + _("...") # don't split strings
+ message: |
+ Incorrect use of translation function detected.
+ Please refer: https://frappeframework.com/docs/user/en/translations
+ languages:
+ - python
+ severity: ERROR
diff --git a/.travis.yml b/.travis.yml
index 53ad56a948..ffada0286f 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -7,7 +7,7 @@ addons:
- test_site_producer
mariadb: 10.3
postgresql: 9.5
- chrome: stable
+ firefox: latest
services:
- xvfb
diff --git a/CODEOWNERS b/CODEOWNERS
index 1afa3f72e3..92723ab035 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -8,10 +8,10 @@ website/ @prssanna
web_form/ @prssanna
templates/ @surajshetty3416
www/ @surajshetty3416
-integrations/ @nextchamp-saqib
+integrations/ @leela
patches/ @surajshetty3416
dashboard/ @prssanna
-email/ @saurabh6790
+email/ @leela
event_streaming/ @ruchamahabal
data_import* @netchampfaris
core/ @surajshetty3416
diff --git a/cypress/integration/recorder.js b/cypress/integration/recorder.js
index 7236200741..d30cc3568c 100644
--- a/cypress/integration/recorder.js
+++ b/cypress/integration/recorder.js
@@ -3,6 +3,16 @@ context('Recorder', () => {
cy.login();
});
+ beforeEach(() => {
+ cy.visit('/app/recorder');
+ return cy.window().its('frappe').then(frappe => {
+ // reset recorder
+ return frappe.xcall("frappe.recorder.stop").then(() => {
+ return frappe.xcall("frappe.recorder.delete");
+ });
+ });
+ });
+
it('Navigate to Recorder', () => {
cy.visit('/app');
cy.awesomebar('recorder');
@@ -11,7 +21,6 @@ context('Recorder', () => {
});
it('Recorder Empty State', () => {
- cy.visit('/app/recorder');
cy.get('.title-text').should('contain', 'Recorder');
cy.get('.indicator-pill').should('contain', 'Inactive').should('have.class', 'red');
@@ -24,7 +33,6 @@ context('Recorder', () => {
});
it('Recorder Start', () => {
- cy.visit('/app/recorder');
cy.get('.primary-action').should('contain', 'Start').click();
cy.get('.indicator-pill').should('contain', 'Active').should('have.class', 'green');
@@ -40,15 +48,9 @@ context('Recorder', () => {
cy.visit('/app/recorder');
cy.get('.title-text').should('contain', 'Recorder');
cy.get('.result-list').should('contain', '/api/method/frappe.desk.reportview.get');
-
- cy.get('#page-recorder .primary-action').should('contain', 'Stop').click();
- cy.wait(500);
- cy.get('#page-recorder .btn-secondary').should('contain', 'Clear').click();
- cy.get('.msg-box').should('contain', 'Inactive');
});
- it('Recorder View Request', () => {
- cy.visit('/app/recorder');
+ it.only('Recorder View Request', () => {
cy.get('.primary-action').should('contain', 'Start').click();
cy.visit('/app/List/DocType/List');
@@ -64,9 +66,5 @@ context('Recorder', () => {
cy.url().should('include', '/recorder/request');
cy.get('form').should('contain', '/api/method/frappe');
-
- cy.get('#page-recorder .primary-action').should('contain', 'Stop').click();
- cy.wait(200);
- cy.get('#page-recorder .btn-secondary').should('contain', 'Clear').click();
});
-});
\ No newline at end of file
+});
diff --git a/frappe/__init__.py b/frappe/__init__.py
index 160ed93c50..ab27bff9fe 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -18,9 +18,14 @@ import os, sys, importlib, inspect, json
from past.builtins import cmp
import click
-# public
+# Local application imports
from .exceptions import *
from .utils.jinja import (get_jenv, get_template, render_template, get_email_from_template, get_jloader)
+from .utils.lazy_loader import lazy_import
+
+# Lazy imports
+faker = lazy_import('faker')
+
# Harmless for Python 3
# For Python 2 set default encoding to utf-8
@@ -1749,15 +1754,13 @@ def parse_json(val):
return parse_json(val)
def mock(type, size=1, locale='en'):
- from faker import Faker
-
results = []
- faker = Faker(locale)
- if not type in dir(faker):
+ fake = faker.Faker(locale)
+ if type not in dir(fake):
raise ValueError('Not a valid mock type.')
else:
for i in range(size):
- data = getattr(faker, type)()
+ data = getattr(fake, type)()
results.append(data)
from frappe.chat.util import squashify
diff --git a/frappe/build.py b/frappe/build.py
index c1c807c8db..baedb633b6 100644
--- a/frappe/build.py
+++ b/frappe/build.py
@@ -15,6 +15,7 @@ import frappe
from frappe.utils.minify import JavascriptMinify
import click
+import psutil
from six import iteritems, text_type
from six.moves.urllib.parse import urlparse
@@ -226,7 +227,7 @@ def bundle(no_compress, app=None, make_copy=False, restore=False, verbose=False,
frappe_app_path = os.path.abspath(os.path.join(app_paths[0], ".."))
check_yarn()
- frappe.commands.popen(command, cwd=frappe_app_path)
+ frappe.commands.popen(command, cwd=frappe_app_path, env=get_node_env())
def watch(no_compress):
@@ -238,13 +239,32 @@ def watch(no_compress):
frappe_app_path = os.path.abspath(os.path.join(app_paths[0], ".."))
check_yarn()
frappe_app_path = frappe.get_app_path("frappe", "..")
- frappe.commands.popen("{pacman} run watch".format(pacman=pacman), cwd=frappe_app_path)
+ frappe.commands.popen("{pacman} run watch".format(pacman=pacman),
+ cwd=frappe_app_path, env=get_node_env())
def check_yarn():
if not find_executable("yarn"):
print("Please install yarn using below command and try again.\nnpm install -g yarn")
+def get_node_env():
+ node_env = {
+ "NODE_OPTIONS": f"--max_old_space_size={get_safe_max_old_space_size()}"
+ }
+ return node_env
+
+def get_safe_max_old_space_size():
+ safe_max_old_space_size = 0
+ try:
+ total_memory = psutil.virtual_memory().total / (1024 * 1024)
+ # reference for the safe limit assumption
+ # https://nodejs.org/api/cli.html#cli_max_old_space_size_size_in_megabytes
+ # set minimum value 1GB
+ safe_max_old_space_size = max(1024, int(total_memory * 0.75))
+ except Exception:
+ pass
+
+ return safe_max_old_space_size
def make_asset_dirs(make_copy=False, restore=False):
# don't even think of making assets_path absolute - rm -rf ahead.
diff --git a/frappe/commands/__init__.py b/frappe/commands/__init__.py
index b7294fff77..b9ae02e112 100644
--- a/frappe/commands/__init__.py
+++ b/frappe/commands/__init__.py
@@ -11,6 +11,7 @@ import frappe.utils
import subprocess # nosec
from functools import wraps
from six import StringIO
+from os import environ
click.disable_unicode_literals_warning = True
@@ -53,16 +54,20 @@ def get_site(context, raise_err=True):
return None
def popen(command, *args, **kwargs):
- output = kwargs.get('output', True)
- cwd = kwargs.get('cwd')
- shell = kwargs.get('shell', True)
+ output = kwargs.get('output', True)
+ cwd = kwargs.get('cwd')
+ shell = kwargs.get('shell', True)
raise_err = kwargs.get('raise_err')
+ env = kwargs.get('env')
+ if env:
+ env = dict(environ, **env)
proc = subprocess.Popen(command,
- stdout = None if output else subprocess.PIPE,
- stderr = None if output else subprocess.PIPE,
- shell = shell,
- cwd = cwd
+ stdout=None if output else subprocess.PIPE,
+ stderr=None if output else subprocess.PIPE,
+ shell=shell,
+ cwd=cwd,
+ env=env
)
return_ = proc.wait()
diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py
index e9fa7217a8..13c6ca812f 100644
--- a/frappe/commands/utils.py
+++ b/frappe/commands/utils.py
@@ -578,7 +578,7 @@ def run_ui_tests(context, app, headless=False):
frappe.commands.popen("yarn add cypress@^6 cypress-file-upload@^5 --no-lockfile")
# run for headless mode
- run_or_open = 'run --browser chrome --record --key 4a48f41c-11b3-425b-aa88-c58048fa69eb' if headless else 'open'
+ run_or_open = 'run --browser firefox --record --key 4a48f41c-11b3-425b-aa88-c58048fa69eb' if headless else 'open'
command = '{site_env} {password_env} {cypress} {run_or_open}'
formatted_command = command.format(site_env=site_env, password_env=password_env, cypress=cypress_path, run_or_open=run_or_open)
diff --git a/frappe/contacts/doctype/contact/contact.py b/frappe/contacts/doctype/contact/contact.py
index 42fa039f74..b3d4c6fc5c 100644
--- a/frappe/contacts/doctype/contact/contact.py
+++ b/frappe/contacts/doctype/contact/contact.py
@@ -97,11 +97,16 @@ class Contact(Document):
if len([email.email_id for email in self.email_ids if email.is_primary]) > 1:
frappe.throw(_("Only one {0} can be set as primary.").format(frappe.bold("Email ID")))
+ primary_email_exists = False
for d in self.email_ids:
if d.is_primary == 1:
+ primary_email_exists = True
self.email_id = d.email_id.strip()
break
+ if not primary_email_exists:
+ self.email_id = ""
+
def set_primary(self, fieldname):
# Used to set primary mobile and phone no.
if len(self.phone_nos) == 0:
@@ -115,11 +120,16 @@ class Contact(Document):
if len(is_primary) > 1:
frappe.throw(_("Only one {0} can be set as primary.").format(frappe.bold(frappe.unscrub(fieldname))))
+ primary_number_exists = False
for d in self.phone_nos:
if d.get(field_name) == 1:
+ primary_number_exists = True
setattr(self, fieldname, d.phone)
break
+ if not primary_number_exists:
+ setattr(self, fieldname, "")
+
def get_default_contact(doctype, name):
'''Returns default contact for the given doctype, name'''
out = frappe.db.sql('''select parent,
diff --git a/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py b/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py
index 2db395102a..9e98dcf6f6 100644
--- a/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py
+++ b/frappe/contacts/report/addresses_and_contacts/test_addresses_and_contacts.py
@@ -103,7 +103,7 @@ class TestAddressesAndContacts(unittest.TestCase):
create_linked_contact(links_list, d)
report_data = get_data({"reference_doctype": "Test Custom Doctype"})
for idx, link in enumerate(links_list):
- test_item = [link, 'test address line 1', 'test address line 2', 'Milan', None, None, 'Italy', 0, '_Test First Name', '_Test Last Name', '_Test Address-Billing', '+91 0000000000', None, 'test_contact@example.com', 1]
+ test_item = [link, 'test address line 1', 'test address line 2', 'Milan', None, None, 'Italy', 0, '_Test First Name', '_Test Last Name', '_Test Address-Billing', '+91 0000000000', '', 'test_contact@example.com', 1]
self.assertListEqual(test_item, report_data[idx])
def tearDown(self):
diff --git a/frappe/core/doctype/data_import/importer.py b/frappe/core/doctype/data_import/importer.py
index dde3dfaee9..7caf69e668 100644
--- a/frappe/core/doctype/data_import/importer.py
+++ b/frappe/core/doctype/data_import/importer.py
@@ -449,8 +449,8 @@ class ImportFile:
data_without_first_row = data[1:]
for row in data_without_first_row:
row_values = row.get_values(parent_column_indexes)
- # if the row is blank, it's a child row doc
- if all([v in INVALID_VALUES for v in row_values]):
+ # if the row is blank or same content as the previous parent row, it's a child row doc
+ if all([v in INVALID_VALUES for v in row_values]) or row_values == parent_row_values:
rows.append(row)
continue
# if we encounter a row which has values in parent columns,
@@ -472,32 +472,6 @@ class ImportFile:
doc = parent_doc
- if self.import_type == INSERT:
- # check if there is atleast one row for mandatory table fields
- meta = frappe.get_meta(self.doctype)
- mandatory_table_fields = [
- df
- for df in meta.fields
- if df.fieldtype in table_fieldtypes
- and df.reqd
- and len(doc.get(df.fieldname, [])) == 0
- ]
- if len(mandatory_table_fields) == 1:
- self.warnings.append(
- {
- "row": first_row.row_number,
- "message": _("There should be atleast one row for {0} table").format(
- frappe.bold(mandatory_table_fields[0].label)
- ),
- }
- )
- elif mandatory_table_fields:
- fields_string = ", ".join([df.label for df in mandatory_table_fields])
- message = _("There should be atleast one row for the following tables: {0}").format(
- fields_string
- )
- self.warnings.append({"row": first_row.row_number, "message": message})
-
return doc, rows, data[len(rows) :]
def get_warnings(self):
@@ -626,7 +600,6 @@ class Row:
new_doc.update(doc)
doc = new_doc
- self.check_mandatory_fields(doctype, doc, table_df)
return doc
def validate_value(self, value, col):
@@ -727,66 +700,6 @@ class Row:
pass
return value
- def check_mandatory_fields(self, doctype, doc, table_df=None):
- """If import type is Insert:
- Check for mandatory fields (except table fields) in doc
- if import type is Update:
- Check for name field or autoname field in doc
- """
- meta = frappe.get_meta(doctype)
- if self.import_type == UPDATE:
- if meta.istable:
- # when updating records with table rows,
- # there are two scenarios:
- # 1. if row 'name' is provided in the template
- # the table row will be updated
- # 2. if row 'name' is not provided
- # then a new row will be added
- # so we dont need to check for mandatory
- return
-
- # for update, only ID (name) field is mandatory
- id_field = get_id_field(doctype)
- if doc.get(id_field.fieldname) in INVALID_VALUES:
- self.warnings.append(
- {
- "row": self.row_number,
- "message": _("{0} is a mandatory field").format(id_field.label),
- }
- )
- return
-
- fields = [
- df
- for df in meta.fields
- if df.fieldtype not in table_fieldtypes
- and df.reqd
- and doc.get(df.fieldname) in INVALID_VALUES
- ]
-
- if not fields:
- return
-
- def get_field_label(df):
- return "{0}{1}".format(df.label, " ({})".format(table_df.label) if table_df else "")
-
- if len(fields) == 1:
- field_label = get_field_label(fields[0])
- self.warnings.append(
- {
- "row": self.row_number,
- "message": _("{0} is a mandatory field").format(frappe.bold(field_label)),
- }
- )
- else:
- fields_string = ", ".join([frappe.bold(get_field_label(df)) for df in fields])
- self.warnings.append(
- {
- "row": self.row_number,
- "message": _("{0} are mandatory fields").format(fields_string),
- }
- )
-
def get_values(self, indexes):
return [self.data[i] for i in indexes]
diff --git a/frappe/core/doctype/data_import/test_importer.py b/frappe/core/doctype/data_import/test_importer.py
index b083b9eaaa..f76d4504a4 100644
--- a/frappe/core/doctype/data_import/test_importer.py
+++ b/frappe/core/doctype/data_import/test_importer.py
@@ -13,7 +13,7 @@ doctype_name = 'DocType for Import'
class TestImporter(unittest.TestCase):
@classmethod
def setUpClass(cls):
- create_doctype_if_not_exists(doctype_name)
+ create_doctype_if_not_exists(doctype_name,)
def test_data_import_from_file(self):
import_file = get_import_file('sample_import_file')
@@ -59,18 +59,18 @@ class TestImporter(unittest.TestCase):
def test_data_import_without_mandatory_values(self):
import_file = get_import_file('sample_import_file_without_mandatory')
data_import = self.get_importer(doctype_name, import_file)
+ frappe.local.message_log = []
data_import.start_import()
data_import.reload()
- warnings = frappe.parse_json(data_import.template_warnings)
+ import_log = frappe.parse_json(data_import.import_log)
+ self.assertEqual(import_log[0]['row_indexes'], [2,3])
+ expected_error = "Error: Child 1 of DocType for Import Row #1: Value missing for: Child Title"
+ self.assertEqual(frappe.parse_json(import_log[0]['messages'][0])['message'], expected_error)
+ expected_error = "Error: Child 1 of DocType for Import Row #2: Value missing for: Child Title"
+ self.assertEqual(frappe.parse_json(import_log[0]['messages'][1])['message'], expected_error)
- self.assertEqual(warnings[0]['row'], 2)
- self.assertEqual(warnings[0]['message'], "Child Title (Table Field 1) is a mandatory field")
-
- self.assertEqual(warnings[1]['row'], 3)
- self.assertEqual(warnings[1]['message'], "Child Title (Table Field 1 Again) is a mandatory field")
-
- self.assertEqual(warnings[2]['row'], 4)
- self.assertEqual(warnings[2]['message'], "Title is a mandatory field")
+ self.assertEqual(import_log[1]['row_indexes'], [4])
+ self.assertEqual(frappe.parse_json(import_log[1]['messages'][0])['message'], "Title is required")
def test_data_import_update(self):
existing_doc = frappe.get_doc(
@@ -104,6 +104,8 @@ class TestImporter(unittest.TestCase):
data_import.reference_doctype = doctype
data_import.import_file = import_file.file_url
data_import.insert()
+ # Commit so that the first import failure does not rollback the Data Import insert.
+ frappe.db.commit()
return data_import
diff --git a/frappe/core/doctype/sms_settings/sms_settings.json b/frappe/core/doctype/sms_settings/sms_settings.json
index 3bb89604af..073fb88bc7 100755
--- a/frappe/core/doctype/sms_settings/sms_settings.json
+++ b/frappe/core/doctype/sms_settings/sms_settings.json
@@ -202,7 +202,7 @@
"issingle": 1,
"istable": 0,
"max_attachments": 0,
- "modified": "2017-11-01 12:57:20.943845",
+ "modified": "2021-03-02 18:06:00.868688",
"modified_by": "Administrator",
"module": "Core",
"name": "SMS Settings",
@@ -233,6 +233,6 @@
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
- "track_changes": 0,
+ "track_changes": 1,
"track_seen": 0
-}
\ No newline at end of file
+}
diff --git a/frappe/core/doctype/user/test_user.py b/frappe/core/doctype/user/test_user.py
index d16db5fecd..8a8071423e 100644
--- a/frappe/core/doctype/user/test_user.py
+++ b/frappe/core/doctype/user/test_user.py
@@ -11,6 +11,7 @@ from frappe.utils import get_url
from frappe.core.doctype.user.user import get_total_users
from frappe.core.doctype.user.user import MaxUsersReachedError, test_password_strength
from frappe.core.doctype.user.user import extract_mentions
+from frappe.frappeclient import FrappeClient
test_records = frappe.get_test_records('User')
@@ -229,16 +230,22 @@ class TestUser(unittest.TestCase):
self.assertEqual(extract_mentions(comment)[1], "test.again@example1.com")
def test_rate_limiting_for_reset_password(self):
- from frappe.utils.password import delete_password_reset_cache
- delete_password_reset_cache()
-
+ # Allow only one reset request for a day
frappe.db.set_value("System Settings", "System Settings", "password_reset_limit", 1)
+ frappe.db.commit()
- user = frappe.get_doc("User", "testperm@example.com")
- link = user.reset_password()
- self.assertRegex(link, "\/update-password\?key=[A-Za-z0-9]*")
+ url = get_url()
+ data={'cmd': 'frappe.core.doctype.user.user.reset_password', 'user': 'test@test.com'}
- self.assertRaises(frappe.ValidationError, user.reset_password, False)
+ # Clear rate limit tracker to start fresh
+ key = f"rl:{data['cmd']}:{data['user']}"
+ frappe.cache().delete(key)
+
+ c = FrappeClient(url)
+ res1 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
+ res2 = c.session.post(url, data=data, verify=c.verify, headers=c.headers)
+ self.assertEqual(res1.status_code, 200)
+ self.assertEqual(res2.status_code, 417)
def test_user_rollback(self):
""" """
diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py
index 3f19a6ef7b..c103ad7e4a 100644
--- a/frappe/core/doctype/user/user.py
+++ b/frappe/core/doctype/user/user.py
@@ -2,21 +2,25 @@
# MIT License. See license.txt
from __future__ import unicode_literals, print_function
+
+from bs4 import BeautifulSoup
+
import frappe
+import frappe.share
+import frappe.defaults
+import frappe.permissions
from frappe.model.document import Document
from frappe.utils import cint, flt, has_gravatar, escape_html, format_datetime, now_datetime, get_formatted_email, today
from frappe import throw, msgprint, _
-from frappe.utils.password import update_password as _update_password, check_password
+from frappe.utils.password import update_password as _update_password, check_password, get_password_reset_limit
from frappe.desk.notifications import clear_notifications
-from frappe.desk.doctype.notification_settings.notification_settings import create_notification_settings
+from frappe.desk.doctype.notification_settings.notification_settings import create_notification_settings, toggle_notifications
from frappe.utils.user import get_system_managers
-from bs4 import BeautifulSoup
-import frappe.permissions
-import frappe.share
-import frappe.defaults
from frappe.website.utils import is_signup_enabled
+from frappe.rate_limiter import rate_limit
from frappe.utils.background_jobs import enqueue
+
STANDARD_USERS = ("Guest", "Administrator")
@@ -146,6 +150,9 @@ class User(Document):
if not cint(self.enabled) and getattr(frappe.local, "login_manager", None):
frappe.local.login_manager.logout(user=self.name)
+ # toggle notifications based on the user's status
+ toggle_notifications(self.name, enable=cint(self.enabled))
+
def add_system_manager_role(self):
# if adding system manager, do nothing
if not cint(self.enabled) or ("System Manager" in [user_role.role for user_role in
@@ -238,11 +245,6 @@ class User(Document):
def reset_password(self, send_email=False, password_expired=False):
from frappe.utils import random_string, get_url
- rate_limit = frappe.db.get_single_value("System Settings", "password_reset_limit")
-
- if rate_limit:
- check_password_reset_limit(self.name, rate_limit)
-
key = random_string(32)
self.db_set("reset_password_key", key)
@@ -254,7 +256,6 @@ class User(Document):
if send_email:
self.password_reset_mail(link)
- update_password_reset_limit(self.name)
return link
def get_other_system_managers(self):
@@ -358,6 +359,9 @@ class User(Document):
set `user`=null
where `user`=%s""", (self.name))
+ # delete notification settings
+ frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True)
+
def before_rename(self, old_name, new_name, merge=False):
self.check_demo()
@@ -837,6 +841,7 @@ def sign_up(email, full_name, redirect_to):
return 2, _("Please ask your administrator to verify your sign-up")
@frappe.whitelist(allow_guest=True)
+@rate_limit(key='user', limit=get_password_reset_limit, seconds = 24*60*60, methods=['POST'])
def reset_password(user):
if user=="Administrator":
return 'not allowed'
@@ -1168,16 +1173,3 @@ def generate_keys(user):
def switch_theme(theme):
if theme in ["Dark", "Light"]:
frappe.db.set_value("User", frappe.session.user, "desk_theme", theme)
-
-def update_password_reset_limit(user):
- generated_link_count = get_generated_link_count(user)
- generated_link_count += 1
- frappe.cache().hset("password_reset_link_count", user, generated_link_count)
-
-def check_password_reset_limit(user, rate_limit):
- generated_link_count = get_generated_link_count(user)
- if generated_link_count >= rate_limit:
- frappe.throw(_("You have reached the hourly limit for generating password reset links. Please try again later."))
-
-def get_generated_link_count(user):
- return cint(frappe.cache().hget("password_reset_link_count", user)) or 0
diff --git a/frappe/core/page/background_jobs/background_jobs.js b/frappe/core/page/background_jobs/background_jobs.js
index cabe91375f..0b4d6792dc 100644
--- a/frappe/core/page/background_jobs/background_jobs.js
+++ b/frappe/core/page/background_jobs/background_jobs.js
@@ -28,6 +28,16 @@ class BackgroundJobs {
}
});
+ // add a "Remove Failed Jobs button"
+ this.remove_failed_button = this.page.add_inner_button(__("Remove Failed Jobs"), () => {
+ frappe.call({
+ method: 'frappe.core.page.background_jobs.background_jobs.remove_failed_jobs',
+ callback: () => {
+ this.refresh_jobs();
+ }
+ });
+ });
+
$(frappe.render_template('background_jobs_outer')).appendTo(this.page.body);
this.content = $(this.page.body).find('.table-area');
}
@@ -62,4 +72,4 @@ class BackgroundJobs {
}
});
}
-}
\ No newline at end of file
+}
diff --git a/frappe/core/page/background_jobs/background_jobs.py b/frappe/core/page/background_jobs/background_jobs.py
index 4a94de4ace..847b23bd3e 100644
--- a/frappe/core/page/background_jobs/background_jobs.py
+++ b/frappe/core/page/background_jobs/background_jobs.py
@@ -1,58 +1,88 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
-from __future__ import unicode_literals
-import frappe
+import json
+from typing import TYPE_CHECKING, Dict, List
from rq import Queue, Worker
-from frappe.utils.background_jobs import get_redis_conn
-from frappe.utils import format_datetime, cint, convert_utc_to_user_timezone
-from frappe.utils.scheduler import is_scheduler_inactive
-from frappe import _
-colors = {
+import frappe
+from frappe import _
+from frappe.utils import convert_utc_to_user_timezone, format_datetime
+from frappe.utils.background_jobs import get_redis_conn
+from frappe.utils.scheduler import is_scheduler_inactive
+
+if TYPE_CHECKING:
+ from rq.job import Job
+
+JOB_COLORS = {
'queued': 'orange',
'failed': 'red',
'started': 'blue',
'finished': 'green'
}
+
@frappe.whitelist()
-def get_info(show_failed=False):
+def get_info(show_failed=False) -> List[Dict]:
+ if isinstance(show_failed, str):
+ show_failed = json.loads(show_failed)
+
conn = get_redis_conn()
queues = Queue.all(conn)
workers = Worker.all(conn)
jobs = []
- def add_job(j, name):
- if j.kwargs.get('site')==frappe.local.site:
- jobs.append({
- 'job_name': j.kwargs.get('kwargs', {}).get('playbook_method') \
- or j.kwargs.get('kwargs', {}).get('job_type') \
- or str(j.kwargs.get('job_name')),
- 'status': j.get_status(), 'queue': name,
- 'creation': format_datetime(convert_utc_to_user_timezone(j.created_at)),
- 'color': colors[j.get_status()]
- })
- if j.exc_info:
- jobs[-1]['exc_info'] = j.exc_info
+ def add_job(job: 'Job', name: str) -> None:
+ if job.kwargs.get('site') == frappe.local.site:
+ job_info = {
+ 'job_name': job.kwargs.get('kwargs', {}).get('playbook_method')
+ or job.kwargs.get('kwargs', {}).get('job_type')
+ or str(job.kwargs.get('job_name')),
+ 'status': job.get_status(),
+ 'queue': name,
+ 'creation': format_datetime(convert_utc_to_user_timezone(job.created_at)),
+ 'color': JOB_COLORS[job.get_status()]
+ }
- for w in workers:
- j = w.get_current_job()
- if j:
- add_job(j, w.name)
+ if job.exc_info:
+ job_info['exc_info'] = job.exc_info
- for q in queues:
- if q.name != 'failed':
- for j in q.get_jobs(): add_job(j, q.name)
+ jobs.append(job_info)
- if cint(show_failed):
- for q in queues:
- if q.name == 'failed':
- for j in q.get_jobs()[:10]: add_job(j, q.name)
+ # show worker jobs
+ for worker in workers:
+ job = worker.get_current_job()
+ if job:
+ add_job(job, worker.name)
+
+ for queue in queues:
+ # show active queued jobs
+ if queue.name != 'failed':
+ for job in queue.jobs:
+ add_job(job, queue.name)
+
+ # show failed jobs, if requested
+ if show_failed:
+ fail_registry = queue.failed_job_registry
+ for job_id in fail_registry.get_job_ids():
+ job = queue.fetch_job(job_id)
+ add_job(job, queue.name)
return jobs
+
+@frappe.whitelist()
+def remove_failed_jobs():
+ conn = get_redis_conn()
+ queues = Queue.all(conn)
+ for queue in queues:
+ fail_registry = queue.failed_job_registry
+ for job_id in fail_registry.get_job_ids():
+ job = queue.fetch_job(job_id)
+ fail_registry.remove(job, delete_job=True)
+
+
@frappe.whitelist()
def get_scheduler_status():
if is_scheduler_inactive():
diff --git a/frappe/core/page/dashboard_view/dashboard_view.js b/frappe/core/page/dashboard_view/dashboard_view.js
index 686d11c6bf..e8e9cc9502 100644
--- a/frappe/core/page/dashboard_view/dashboard_view.js
+++ b/frappe/core/page/dashboard_view/dashboard_view.js
@@ -36,17 +36,17 @@ class Dashboard {
} else {
// last opened
if (frappe.last_dashboard) {
- frappe.set_route('dashboard-view', frappe.last_dashboard);
+ frappe.set_re_route('dashboard-view', frappe.last_dashboard);
} else {
// default dashboard
frappe.db.get_list('Dashboard', {filters: {is_default: 1}}).then(data => {
if (data && data.length) {
- frappe.set_route('dashboard-view', data[0].name);
+ frappe.set_re_route('dashboard-view', data[0].name);
} else {
// no default, get the latest one
frappe.db.get_list('Dashboard', {limit: 1}).then(data => {
if (data && data.length) {
- frappe.set_route('dashboard-view', data[0].name);
+ frappe.set_re_route('dashboard-view', data[0].name);
} else {
// create a new dashboard!
frappe.new_doc('Dashboard');
diff --git a/frappe/core/page/recorder/recorder.js b/frappe/core/page/recorder/recorder.js
index 4d6d6aa84c..b75ea6a41c 100644
--- a/frappe/core/page/recorder/recorder.js
+++ b/frappe/core/page/recorder/recorder.js
@@ -22,6 +22,7 @@ class Recorder {
}
show() {
-
+ if (!this.view || this.view.$route.name == "recorder-detail") return;
+ this.view.$router.replace({name: "recorder-detail"});
}
}
diff --git a/frappe/custom/doctype/customize_form/customize_form.js b/frappe/custom/doctype/customize_form/customize_form.js
index 79978a49d7..d9d8ae196e 100644
--- a/frappe/custom/doctype/customize_form/customize_form.js
+++ b/frappe/custom/doctype/customize_form/customize_form.js
@@ -76,6 +76,7 @@ frappe.ui.form.on("Customize Form", {
frm.trigger("setup_sortable");
}
}
+ localStorage["customize_doctype"] = frm.doc.doc_type;
}
});
} else {
diff --git a/frappe/desk/doctype/notification_settings/notification_settings.py b/frappe/desk/doctype/notification_settings/notification_settings.py
index 34726bdf8a..4ab40bffe9 100644
--- a/frappe/desk/doctype/notification_settings/notification_settings.py
+++ b/frappe/desk/doctype/notification_settings/notification_settings.py
@@ -44,6 +44,11 @@ def create_notification_settings(user):
_doc.insert(ignore_permissions=True)
+def toggle_notifications(user, enable=False):
+ if frappe.db.exists("Notification Settings", user):
+ frappe.db.set_value("Notification Settings", user, 'enabled', enable)
+
+
@frappe.whitelist()
def get_subscribed_documents():
if not frappe.session.user:
@@ -75,4 +80,4 @@ def get_permission_query_conditions(user):
@frappe.whitelist()
def set_seen_value(value, user):
- frappe.db.set_value('Notification Settings', user, 'seen', value, update_modified=False)
\ No newline at end of file
+ frappe.db.set_value('Notification Settings', user, 'seen', value, update_modified=False)
diff --git a/frappe/desk/leaderboard.py b/frappe/desk/leaderboard.py
index 8d00ea9bc2..2a981f061b 100644
--- a/frappe/desk/leaderboard.py
+++ b/frappe/desk/leaderboard.py
@@ -16,8 +16,18 @@ def get_leaderboards():
@frappe.whitelist()
def get_energy_point_leaderboard(date_range, company = None, field = None, limit = None):
+ all_users = frappe.db.get_all('User',
+ filters = {
+ 'name': ['not in', ['Administrator', 'Guest']],
+ 'enabled': 1,
+ 'user_type': ['!=', 'Website User']
+ },
+ order_by = 'name ASC')
+ all_users_list = list(map(lambda x: x['name'], all_users))
+
filters = [
['type', '!=', 'Review'],
+ ['user', 'in', all_users_list]
]
if date_range:
date_range = frappe.parse_json(date_range)
@@ -28,15 +38,7 @@ def get_energy_point_leaderboard(date_range, company = None, field = None, limit
group_by = 'user',
order_by = 'value desc'
)
- all_users = frappe.db.get_all('User',
- filters = {
- 'name': ['not in', ['Administrator', 'Guest']],
- 'enabled': 1,
- 'user_type': ['!=', 'Website User']
- },
- order_by = 'name ASC')
- all_users_list = list(map(lambda x: x['name'], all_users))
energy_point_users_list = list(map(lambda x: x['name'], energy_point_users))
for user in all_users_list:
if user not in energy_point_users_list:
diff --git a/frappe/hooks.py b/frappe/hooks.py
index 3e206f0ad3..c9914237fe 100644
--- a/frappe/hooks.py
+++ b/frappe/hooks.py
@@ -207,8 +207,7 @@ scheduler_events = {
"frappe.deferred_insert.save_to_db",
"frappe.desk.form.document_follow.send_hourly_updates",
"frappe.integrations.doctype.google_calendar.google_calendar.sync",
- "frappe.email.doctype.newsletter.newsletter.send_scheduled_email",
- "frappe.utils.password.delete_password_reset_cache"
+ "frappe.email.doctype.newsletter.newsletter.send_scheduled_email"
],
"daily": [
"frappe.email.queue.set_expiry_for_email_queue",
diff --git a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py
index 71445b44d7..09da1ecc42 100644
--- a/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py
+++ b/frappe/integrations/doctype/dropbox_settings/dropbox_settings.py
@@ -131,12 +131,10 @@ def upload_from_folder(path, is_private, dropbox_folder, dropbox_client, did_not
for f in frappe.get_all("File", filters={"is_folder": 0, "is_private": is_private,
"uploaded_to_dropbox": 0}, fields=['file_url', 'name', 'file_name']):
- if is_private:
- filename = f.file_url.replace('/private/files/', '')
- else:
- if not f.file_url:
- f.file_url = '/files/' + f.file_name;
- filename = f.file_url.replace('/files/', '')
+ if not f.file_url:
+ continue
+ filename = f.file_url.rsplit('/', 1)[-1]
+
filepath = os.path.join(path, filename)
if filename in ignore_list:
diff --git a/frappe/patches.txt b/frappe/patches.txt
index d43690eac2..6e94bf0adc 100644
--- a/frappe/patches.txt
+++ b/frappe/patches.txt
@@ -331,3 +331,5 @@ execute:frappe.get_doc('Role', 'Guest').save() # remove desk access
frappe.patches.v13_0.rename_desk_page_to_workspace # 02.02.2021
frappe.patches.v13_0.delete_package_publish_tool
frappe.patches.v13_0.rename_list_view_setting_to_list_view_settings
+frappe.patches.v13_0.remove_twilio_settings
+frappe.patches.v12_0.rename_uploaded_files_with_proper_name
diff --git a/frappe/patches/v12_0/rename_uploaded_files_with_proper_name.py b/frappe/patches/v12_0/rename_uploaded_files_with_proper_name.py
new file mode 100644
index 0000000000..854a381e1c
--- /dev/null
+++ b/frappe/patches/v12_0/rename_uploaded_files_with_proper_name.py
@@ -0,0 +1,31 @@
+import frappe
+import os
+
+def execute():
+ file_names_with_url = frappe.get_all("File", filters={
+ "is_folder": 0,
+ "file_name": ["like", "%/%"]
+ }, fields=['name', 'file_name', 'file_url'])
+
+ for f in file_names_with_url:
+ filename = f.file_name.rsplit('/', 1)[-1]
+
+ if not f.file_url:
+ f.file_url = f.file_name
+
+ try:
+ if not file_exists(f.file_url):
+ continue
+ frappe.db.set_value('File', f.name, {
+ "file_name": filename,
+ "file_url": f.file_url
+ }, update_modified=False)
+ except Exception:
+ continue
+
+def file_exists(file_path):
+ file_path = frappe.utils.get_files_path(
+ file_path.rsplit('/', 1)[-1],
+ is_private=file_path.startswith('/private')
+ )
+ return os.path.exists(file_path)
diff --git a/frappe/patches/v13_0/remove_twilio_settings.py b/frappe/patches/v13_0/remove_twilio_settings.py
new file mode 100644
index 0000000000..363cbdd4b6
--- /dev/null
+++ b/frappe/patches/v13_0/remove_twilio_settings.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See license.txt
+
+import frappe
+
+
+def execute():
+ """Add missing Twilio patch.
+
+ While making Twilio as a standaone app, we missed to delete Twilio records from DB through migration. Adding the missing patch.
+ """
+ frappe.delete_doc_if_exists('DocType', 'Twilio Number Group')
+ if twilio_settings_doctype_in_integrations():
+ frappe.delete_doc_if_exists('DocType', 'Twilio Settings')
+ frappe.db.sql("delete from `tabSingles` where `doctype`=%s", 'Twilio Settings')
+
+def twilio_settings_doctype_in_integrations() -> bool:
+ """Check Twilio Settings doctype exists in integrations module or not.
+ """
+ return frappe.db.exists("DocType", {'name': 'Twilio Settings', 'module': 'Integrations'})
diff --git a/frappe/printing/doctype/print_format/print_format.json b/frappe/printing/doctype/print_format/print_format.json
index 92d4a67d14..4032cef209 100644
--- a/frappe/printing/doctype/print_format/print_format.json
+++ b/frappe/printing/doctype/print_format/print_format.json
@@ -171,7 +171,7 @@
"fieldname": "custom_html_help",
"fieldtype": "HTML",
"label": "Custom HTML Help",
- "options": "
Custom CSS Help
\n\nNotes:
\n\n\n- All field groups (label + value) are set attributes
data-fieldtype and data-fieldname \n- All values are given class
value \n- All Section Breaks are given class
section-break \n- All Column Breaks are given class
column-break \n
\n\nExamples
\n\n1. Left align integers
\n\n[data-fieldtype=\"Int\"] .value { text-left: left; }
\n\n1. Add border to sections except the last section
\n\n.section-break { padding: 30px 0px; border-bottom: 1px solid #eee; }\n.section-break:last-child { padding-bottom: 0px; border-bottom: 0px; }
\n"
+ "options": "Custom CSS Help
\n\nNotes:
\n\n\n- All field groups (label + value) are set attributes
data-fieldtype and data-fieldname \n- All values are given class
value \n- All Section Breaks are given class
section-break \n- All Column Breaks are given class
column-break \n
\n\nExamples
\n\n1. Left align integers
\n\n[data-fieldtype=\"Int\"] .value { text-align: left; }
\n\n1. Add border to sections except the last section
\n\n.section-break { padding: 30px 0px; border-bottom: 1px solid #eee; }\n.section-break:last-child { padding-bottom: 0px; border-bottom: 0px; }
\n"
},
{
"depends_on": "custom_format",
@@ -211,7 +211,7 @@
"idx": 1,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2020-12-14 11:38:49.132061",
+ "modified": "2021-03-01 15:25:46.578863",
"modified_by": "Administrator",
"module": "Printing",
"name": "Print Format",
diff --git a/frappe/printing/page/print/print.js b/frappe/printing/page/print/print.js
index 0ae8786e95..dfd93c4efa 100644
--- a/frappe/printing/page/print/print.js
+++ b/frappe/printing/page/print/print.js
@@ -269,6 +269,7 @@ frappe.ui.form.PrintView = class {
based_on: data.based_on,
};
frappe.set_route('print-format-builder');
+ this.print_sel.val(data.print_format_name);
},
__('New Custom Print Format'),
__('Start')
@@ -641,10 +642,13 @@ frappe.ui.form.PrintView = class {
refresh_print_options() {
this.print_formats = frappe.meta.get_print_formats(this.frm.doctype);
- return this.print_sel.empty().add_options([
+ const print_format_select_val = this.print_sel.val();
+ this.print_sel.empty().add_options([
this.get_default_option_for_select(__('Select Print Format')),
...this.print_formats
]);
+ return this.print_formats.includes(print_format_select_val)
+ && this.print_sel.val(print_format_select_val);
}
selected_format() {
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 eb87190ab5..7e58e295b5 100644
--- a/frappe/printing/page/print_format_builder/print_format_builder.js
+++ b/frappe/printing/page/print_format_builder/print_format_builder.js
@@ -784,6 +784,7 @@ frappe.PrintFormatBuilder = Class.extend({
btn: this.page.btn_primary,
callback: function(r) {
me.print_format = r.message;
+ locals['Print Format'][me.print_format.name] = r.message;
frappe.show_alert({message: __("Saved"), indicator: 'green'});
}
});
diff --git a/frappe/printing/page/print_format_builder/print_format_builder_sidebar.html b/frappe/printing/page/print_format_builder/print_format_builder_sidebar.html
index 0cf8178f82..1ebb87ac31 100644
--- a/frappe/printing/page/print_format_builder/print_format_builder_sidebar.html
+++ b/frappe/printing/page/print_format_builder/print_format_builder_sidebar.html
@@ -13,7 +13,7 @@
- {%= __(f.label) %}
+ {%= __(f.label) || __(f.fieldname) %}
{% } %}
diff --git a/frappe/public/icons/timeless/icon-right-arrow.svg b/frappe/public/icons/timeless/icon-right-arrow.svg
deleted file mode 100644
index 1e044d0e4d..0000000000
--- a/frappe/public/icons/timeless/icon-right-arrow.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
\ No newline at end of file
diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js
index e033ae4c5b..d59bd4cdb7 100644
--- a/frappe/public/js/frappe/desk.js
+++ b/frappe/public/js/frappe/desk.js
@@ -201,12 +201,13 @@ frappe.Application = Class.extend({
email_password_prompt: function(email_account,user,i) {
var me = this;
- var d = new frappe.ui.Dialog({
- title: __('Email Account setup please enter your password for: {0}', [email_account[i]["email_id"]]),
+ let d = new frappe.ui.Dialog({
+ title: __('Password missing in Email Account'),
fields: [
- { 'fieldname': 'password',
+ {
+ 'fieldname': 'password',
'fieldtype': 'Password',
- 'label': 'Email Account Password',
+ 'label': __('Please enter the password for: {0}', [email_account[i]["email_id"]]),
'reqd': 1
},
{
diff --git a/frappe/public/js/frappe/form/controls/attach.js b/frappe/public/js/frappe/form/controls/attach.js
index fe662c1ada..604510bb52 100644
--- a/frappe/public/js/frappe/form/controls/attach.js
+++ b/frappe/public/js/frappe/form/controls/attach.js
@@ -1,6 +1,6 @@
frappe.ui.form.ControlAttach = frappe.ui.form.ControlData.extend({
make_input: function() {
- var me = this;
+ let me = this;
this.$input = $('