diff --git a/cypress/integration/table_multiselect.js b/cypress/integration/table_multiselect.js index 8b83a0d914..faa72d63a5 100644 --- a/cypress/integration/table_multiselect.js +++ b/cypress/integration/table_multiselect.js @@ -8,7 +8,7 @@ context('Table MultiSelect', () => { it('select value from multiselect dropdown', () => { cy.new_form('Assignment Rule'); cy.fill_field('__newname', name); - cy.fill_field('document_type', 'ToDo'); + cy.fill_field('document_type', 'Blog Post'); cy.fill_field('assign_condition', 'status=="Open"', 'Code'); cy.get('input[data-fieldname="users"]').focus().as('input'); cy.get('input[data-fieldname="users"] + ul').should('be.visible'); diff --git a/frappe/__init__.py b/frappe/__init__.py index 9b3ffc4662..acb261b639 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -17,7 +17,6 @@ from werkzeug.local import Local, release_local import os, sys, importlib, inspect, json from past.builtins import cmp import click -from faker import Faker # public from .exceptions import * @@ -196,17 +195,20 @@ def init(site, sites_path=None, new_site=False): local.initialised = True -def connect(site=None, db_name=None): +def connect(site=None, db_name=None, set_admin_as_user=True): """Connect to site database instance. :param site: If site is given, calls `frappe.init`. - :param db_name: Optional. Will use from `site_config.json`.""" + :param db_name: Optional. Will use from `site_config.json`. + :param set_admin_as_user: Set Administrator as current user. + """ from frappe.database import get_db if site: init(site) local.db = get_db(user=db_name or local.conf.db_name) - set_user("Administrator") + if set_admin_as_user: + set_user("Administrator") def connect_replica(): from frappe.database import get_db @@ -1747,6 +1749,8 @@ 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): diff --git a/frappe/app.py b/frappe/app.py index 29ef69ef2d..607479ad52 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -128,6 +128,8 @@ def init_request(request): if frappe.local.conf.get('maintenance_mode'): frappe.connect() raise frappe.SessionStopped('Session Stopped') + else: + frappe.connect(set_admin_as_user=False) make_form_dict(request) @@ -152,10 +154,10 @@ def process_response(response): def set_cors_headers(response): origin = frappe.request.headers.get('Origin') - if not origin: + allow_cors = frappe.conf.allow_cors + if not (origin and allow_cors): return - allow_cors = frappe.conf.allow_cors if allow_cors != "*": if not isinstance(allow_cors, list): allow_cors = [allow_cors] diff --git a/frappe/auth.py b/frappe/auth.py index 2e0ec681d2..946a8c52d5 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -207,23 +207,44 @@ class LoginManager: if frappe.session.user != "Guest": clear_sessions(frappe.session.user, keep_current=True) - def authenticate(self, user=None, pwd=None): + def authenticate(self, user: str = None, pwd: str = None): + from frappe.core.doctype.user.user import User + if not (user and pwd): user, pwd = frappe.form_dict.get('usr'), frappe.form_dict.get('pwd') if not (user and pwd): self.fail(_('Incomplete login details'), user=user) - if cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number")): - user = frappe.db.get_value("User", filters={"mobile_no": user}, fieldname="name") or user + # Ignore password check if tmp_id is set, 2FA takes care of authentication. + validate_password = not bool(frappe.form_dict.get('tmp_id')) + user = User.find_by_credentials(user, pwd, validate_password=validate_password) - if cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_user_name")): - user = frappe.db.get_value("User", filters={"username": user}, fieldname="name") or user + if not user: + self.fail('Invalid login credentials') - self.check_if_enabled(user) - if not frappe.form_dict.get('tmp_id'): - self.user = self.check_password(user, pwd) + sys_settings = frappe.get_doc("System Settings") + track_login_attempts = (sys_settings.allow_consecutive_login_attempts >0) + + tracker_kwargs = {} + if track_login_attempts: + tracker_kwargs['lock_interval'] = sys_settings.allow_login_after_fail + tracker_kwargs['max_consecutive_login_attempts'] = sys_settings.allow_consecutive_login_attempts + + tracker = LoginAttemptTracker(user.name, **tracker_kwargs) + + if track_login_attempts and not tracker.is_user_allowed(): + frappe.throw(_("Your account has been locked and will resume after {0} seconds") + .format(sys_settings.allow_login_after_fail), frappe.SecurityException) + + if not user.is_authenticated: + tracker.add_failure_attempt() + self.fail('Invalid login credentials', user=user.name) + elif not (user.name == 'Administrator' or user.enabled): + tracker.add_failure_attempt() + self.fail('User disabled or missing', user=user.name) else: - self.user = user + tracker.add_success_attempt() + self.user = user.name def force_user_to_reset_password(self): if not self.user: @@ -245,23 +266,12 @@ class LoginManager: if last_pwd_reset_days > reset_pwd_after_days: return True - def check_if_enabled(self, user): - """raise exception if user not enabled""" - doc = frappe.get_doc("System Settings") - if cint(doc.allow_consecutive_login_attempts) > 0: - check_consecutive_login_attempts(user, doc) - - if user=='Administrator': return - if not cint(frappe.db.get_value('User', user, 'enabled')): - self.fail('User disabled or missing', user=user) - def check_password(self, user, pwd): """check password""" try: # returns user in correct case return check_password(user, pwd) except frappe.AuthenticationError: - self.update_invalid_login(user) self.fail('Incorrect password', user=user) def fail(self, message, user=None): @@ -272,15 +282,6 @@ class LoginManager: frappe.db.commit() raise frappe.AuthenticationError - def update_invalid_login(self, user): - last_login_tried = get_last_tried_login_data(user) - - failed_count = 0 - if last_login_tried > get_datetime(): - failed_count = get_login_failed_count(user) - - frappe.cache().hset('login_failed_count', user, failed_count + 1) - def run_trigger(self, event='on_login'): for method in frappe.get_hooks().get(event, []): frappe.call(frappe.get_attr(method), login_manager=self) @@ -383,38 +384,6 @@ def clear_cookies(): frappe.session.sid = "" frappe.local.cookie_manager.delete_cookie(["full_name", "user_id", "sid", "user_image", "system_user"]) -def get_last_tried_login_data(user, get_last_login=False): - locked_account_time = frappe.cache().hget('locked_account_time', user) - if get_last_login and locked_account_time: - return locked_account_time - - last_login_tried = frappe.cache().hget('last_login_tried', user) - if not last_login_tried or last_login_tried < get_datetime(): - last_login_tried = get_datetime() + datetime.timedelta(seconds=60) - - frappe.cache().hset('last_login_tried', user, last_login_tried) - - return last_login_tried - -def get_login_failed_count(user): - return cint(frappe.cache().hget('login_failed_count', user)) or 0 - -def check_consecutive_login_attempts(user, doc): - login_failed_count = get_login_failed_count(user) - last_login_tried = (get_last_tried_login_data(user, True) - + datetime.timedelta(seconds=doc.allow_login_after_fail)) - - if login_failed_count >= cint(doc.allow_consecutive_login_attempts): - locked_account_time = frappe.cache().hget('locked_account_time', user) - if not locked_account_time: - frappe.cache().hset('locked_account_time', user, get_datetime()) - - if last_login_tried > get_datetime(): - frappe.throw(_("Your account has been locked and will resume after {0} seconds") - .format(doc.allow_login_after_fail), frappe.SecurityException) - else: - delete_login_failed_cache(user) - 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) @@ -436,3 +405,87 @@ def validate_ip_address(user): return frappe.throw(_("Access not allowed from this IP Address"), frappe.AuthenticationError) + + +class LoginAttemptTracker(object): + """Track login attemts of a user. + + Lock the account for s number of seconds if there have been n consecutive unsuccessful attempts to log in. + """ + def __init__(self, user_name: str, max_consecutive_login_attempts: int=3, lock_interval:int = 5*60): + """ Initialize the tracker. + + :param user_name: Name of the loggedin user + :param max_consecutive_login_attempts: Maximum allowed consecutive failed login attempts + :param lock_interval: Locking interval incase of maximum failed attempts + """ + self.user_name = user_name + self.lock_interval = datetime.timedelta(seconds=lock_interval) + self.max_failed_logins = max_consecutive_login_attempts + + @property + def login_failed_count(self): + return frappe.cache().hget('login_failed_count', self.user_name) + + @login_failed_count.setter + def login_failed_count(self, count): + frappe.cache().hset('login_failed_count', self.user_name, count) + + @login_failed_count.deleter + def login_failed_count(self): + frappe.cache().hdel('login_failed_count', self.user_name) + + @property + def login_failed_time(self): + """First failed login attempt time within lock interval. + + For every user we track only First failed login attempt time within lock interval of time. + """ + return frappe.cache().hget('login_failed_time', self.user_name) + + @login_failed_time.setter + def login_failed_time(self, timestamp): + frappe.cache().hset('login_failed_time', self.user_name, timestamp) + + @login_failed_time.deleter + def login_failed_time(self): + frappe.cache().hdel('login_failed_time', self.user_name) + + def add_failure_attempt(self): + """ Log user failure attempts into the system. + + Increase the failure count if new failure is with in current lock interval time period, if not reset the login failure count. + """ + login_failed_time = self.login_failed_time + login_failed_count = self.login_failed_count # Consecutive login failure count + current_time = get_datetime() + + if not (login_failed_time and login_failed_count): + login_failed_time, login_failed_count = current_time, 0 + + if login_failed_time + self.lock_interval > current_time: + login_failed_count += 1 + else: + login_failed_time, login_failed_count = current_time, 1 + + self.login_failed_time = login_failed_time + self.login_failed_count = login_failed_count + + def add_success_attempt(self): + """Reset login failures. + """ + del self.login_failed_count + del self.login_failed_time + + def is_user_allowed(self) -> bool: + """Is user allowed to login + + User is not allowed to login if login failures are greater than threshold within in lock interval from first login failure. + """ + login_failed_time = self.login_failed_time + login_failed_count = self.login_failed_count or 0 + current_time = get_datetime() + + if login_failed_time and login_failed_time + self.lock_interval > current_time and login_failed_count > self.max_failed_logins: + return False + return True diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.js b/frappe/automation/doctype/assignment_rule/assignment_rule.js index ee1a076465..97bed4f8f3 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.js +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.js @@ -9,6 +9,16 @@ frappe.ui.form.on('Assignment Rule', { frm.events.rule(frm); }, + setup: function(frm) { + frm.set_query("document_type", () => { + return { + filters: { + name: ["!=", "ToDo"] + } + }; + }); + }, + document_type: function(frm) { frm.trigger('set_options'); }, diff --git a/frappe/automation/doctype/assignment_rule/assignment_rule.py b/frappe/automation/doctype/assignment_rule/assignment_rule.py index d20398d564..c673d5ceeb 100644 --- a/frappe/automation/doctype/assignment_rule/assignment_rule.py +++ b/frappe/automation/doctype/assignment_rule/assignment_rule.py @@ -18,6 +18,8 @@ class AssignmentRule(Document): if not len(set(assignment_days)) == len(assignment_days): repeated_days = get_repeated(assignment_days) frappe.throw(_("Assignment Day {0} has been repeated.").format(frappe.bold(repeated_days))) + if self.document_type == 'ToDo': + frappe.throw(_('Assignment Rule is not allowed on {0} document type').format(frappe.bold("ToDo"))) def on_update(self): clear_assignment_rule_cache(self) @@ -298,4 +300,4 @@ def get_repeated(values): def clear_assignment_rule_cache(rule): frappe.cache_manager.clear_doctype_map('Assignment Rule', rule.document_type) - frappe.cache_manager.clear_doctype_map('Assignment Rule', 'due_date_rules_for_' + rule.document_type) \ No newline at end of file + frappe.cache_manager.clear_doctype_map('Assignment Rule', 'due_date_rules_for_' + rule.document_type) diff --git a/frappe/build.py b/frappe/build.py index f47a7cb32b..c1c807c8db 100644 --- a/frappe/build.py +++ b/frappe/build.py @@ -15,7 +15,6 @@ import frappe from frappe.utils.minify import JavascriptMinify import click -from requests import get from six import iteritems, text_type from six.moves.urllib.parse import urlparse @@ -26,6 +25,8 @@ sites_path = os.path.abspath(os.getcwd()) def download_file(url, prefix): + from requests import get + filename = urlparse(url).path.split("/")[-1] local_filename = os.path.join(prefix, filename) with get(url, stream=True, allow_redirects=True) as r: diff --git a/frappe/commands/site.py b/frappe/commands/site.py index 4a631be3ac..0fadf2a294 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -9,7 +9,6 @@ import click import frappe from frappe.commands import get_site, pass_context from frappe.exceptions import SiteNotSpecifiedError -from frappe.installer import _new_site @click.command('new-site') @@ -31,6 +30,8 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin verbose=False, install_apps=None, source_sql=None, force=None, no_mariadb_socket=False, install_app=None, db_name=None, db_password=None, db_type=None, db_host=None, db_port=None): "Create a new site" + from frappe.installer import _new_site + frappe.init(site=site, new_site=True) _new_site(db_name, site, mariadb_root_username=mariadb_root_username, @@ -57,6 +58,7 @@ def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin def restore(context, sql_file_path, mariadb_root_username=None, mariadb_root_password=None, db_name=None, verbose=None, install_app=None, admin_password=None, force=None, with_public_files=None, with_private_files=None): "Restore site database from an sql file" from frappe.installer import ( + _new_site, extract_sql_from_archive, extract_files, is_downgrade, @@ -145,6 +147,8 @@ def reinstall(context, admin_password=None, mariadb_root_username=None, mariadb_ _reinstall(site, admin_password, mariadb_root_username, mariadb_root_password, yes, verbose=context.verbose) def _reinstall(site, admin_password=None, mariadb_root_username=None, mariadb_root_password=None, yes=False, verbose=False): + from frappe.installer import _new_site + if not yes: click.confirm('This will wipe your database. Are you sure you want to reinstall?', abort=True) try: diff --git a/frappe/core/doctype/activity_log/test_activity_log.py b/frappe/core/doctype/activity_log/test_activity_log.py index 4dbfd6700e..bd0ea08cc7 100644 --- a/frappe/core/doctype/activity_log/test_activity_log.py +++ b/frappe/core/doctype/activity_log/test_activity_log.py @@ -77,6 +77,10 @@ class TestActivityLog(unittest.TestCase): self.assertRaises(frappe.AuthenticationError, LoginManager) self.assertRaises(frappe.AuthenticationError, LoginManager) self.assertRaises(frappe.AuthenticationError, LoginManager) + + # REMOVE ME: current logic allows allow_consecutive_login_attempts+1 attempts + # before raising security exception, remove below line when that is fixed. + self.assertRaises(frappe.AuthenticationError, LoginManager) self.assertRaises(frappe.SecurityException, LoginManager) time.sleep(5) self.assertRaises(frappe.AuthenticationError, LoginManager) diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 142cc1ee26..3f19a6ef7b 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -6,7 +6,7 @@ import frappe 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 +from frappe.utils.password import update_password as _update_password, check_password from frappe.desk.notifications import clear_notifications from frappe.desk.doctype.notification_settings.notification_settings import create_notification_settings from frappe.utils.user import get_system_managers @@ -527,6 +527,27 @@ class User(Document): return [i.strip() for i in self.restrict_ip.split(",")] + @classmethod + def find_by_credentials(cls, user_name: str, password: str, validate_password: bool = True): + """Find the user by credentials. + """ + login_with_mobile = cint(frappe.db.get_value("System Settings", "System Settings", "allow_login_using_mobile_number")) + filter = {"mobile_no": user_name} if login_with_mobile else {"name": user_name} + + user = frappe.db.get_value("User", filters=filter, fieldname=['name', 'enabled'], as_dict=True) or {} + if not user: + return + + user['is_authenticated'] = True + if validate_password: + try: + check_password(user_name, password) + except frappe.AuthenticationError: + user['is_authenticated'] = False + + return user + + @frappe.whitelist() def get_timezones(): import pytz diff --git a/frappe/database/database.py b/frappe/database/database.py index 179206a4af..4fcf10efda 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -16,7 +16,6 @@ import frappe.model.meta from frappe import _ from time import time from frappe.utils import now, getdate, cast_fieldtype, get_datetime -from frappe.utils.background_jobs import execute_job, get_queue from frappe.model.utils.link_count import flush_local_link_count from frappe.utils import cint @@ -1032,6 +1031,8 @@ class Database(object): insert_list = [] def enqueue_jobs_after_commit(): + from frappe.utils.background_jobs import execute_job, get_queue + if frappe.flags.enqueue_after_commit and len(frappe.flags.enqueue_after_commit) > 0: for job in frappe.flags.enqueue_after_commit: q = get_queue(job.get("queue"), is_async=job.get("is_async")) diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index 3cbb2e4f0e..f9997d1526 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -8,8 +8,7 @@ from pymysql.times import TimeDelta from pymysql.constants import ER, FIELD_TYPE from pymysql.converters import conversions -from frappe.utils import get_datetime, cstr -from markdown2 import UnicodeWithAttrs +from frappe.utils import get_datetime, cstr, UnicodeWithAttrs from frappe.database.database import Database from six import PY2, binary_type, text_type, string_types from frappe.database.mariadb.schema import MariaDBTable diff --git a/frappe/desk/form/document_follow.py b/frappe/desk/form/document_follow.py index f5ace4d732..f5e5c0ca9b 100644 --- a/frappe/desk/form/document_follow.py +++ b/frappe/desk/form/document_follow.py @@ -147,6 +147,8 @@ def get_version(doctype, doc_name, frequency, user): return timeline def get_comments(doctype, doc_name, frequency, user): + from html2text import html2text + timeline = [] filters = get_filters("reference_name", doc_name, frequency, user) comments = frappe.get_all("Comment", @@ -166,7 +168,7 @@ def get_comments(doctype, doc_name, frequency, user): "time": comment.modified, "data": { "time": time, - "comment": frappe.utils.html2text(str(comment.content)), + "comment": html2text(str(comment.content)), "by": by }, "doctype": doctype, @@ -197,6 +199,8 @@ def get_follow_users(doctype, doc_name): ) def get_row_changed(row_changed, time, doctype, doc_name, v): + from html2text import html2text + items = [] for d in row_changed: d[2] = d[2] if d[2] else ' ' @@ -209,8 +213,8 @@ def get_row_changed(row_changed, time, doctype, doc_name, v): "table_field": d[0], "row": str(d[1]), "field": d[3][0][0], - "from": frappe.utils.html2text(str(d[3][0][1])), - "to": frappe.utils.html2text(str(d[3][0][2])) + "from": html2text(str(d[3][0][1])), + "to": html2text(str(d[3][0][2])) }, "doctype": doctype, "doc_name": doc_name, @@ -236,6 +240,8 @@ def get_added_row(added, time, doctype, doc_name, v): return items def get_field_changed(changed, time, doctype, doc_name, v): + from html2text import html2text + items = [] for d in changed: d[1] = d[1] if d[1] else ' ' @@ -246,8 +252,8 @@ def get_field_changed(changed, time, doctype, doc_name, v): "data": { "time": time, "field": d[0], - "from": frappe.utils.html2text(str(d[1])), - "to": frappe.utils.html2text(str(d[2])) + "from": html2text(str(d[1])), + "to": html2text(str(d[2])) }, "doctype": doctype, "doc_name": doc_name, diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index ca4dbb83e2..4869c5a9bf 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -90,6 +90,29 @@ class EmailAccount(Document): if self.append_to not in valid_doctypes: frappe.throw(_("Append To can be one of {0}").format(comma_or(valid_doctypes))) + def before_save(self): + messages = [] + as_list = 1 + if not self.enable_incoming and self.default_incoming: + self.default_incoming = False + messages.append(_("{} has been disabled. It can only be enabled if {} is checked.") + .format( + frappe.bold(_('Default Incoming')), + frappe.bold(_('Enable Incoming')) + ) + ) + if not self.enable_outgoing and self.default_outgoing: + self.default_outgoing = False + messages.append(_("{} has been disabled. It can only be enabled if {} is checked.") + .format( + frappe.bold(_('Default Outgoing')), + frappe.bold(_('Enable Outgoing')) + ) + ) + if messages: + if len(messages) == 1: (as_list, messages) = (0, messages[0]) + frappe.msgprint(messages, as_list= as_list, indicator='orange', title=_("Defaults Updated")) + def on_update(self): """Check there is only one default of each type.""" from frappe.core.doctype.user.user import setup_user_email_inbox diff --git a/frappe/integrations/doctype/connected_app/test_connected_app.py b/frappe/integrations/doctype/connected_app/test_connected_app.py index 6faa542a60..b4304f6ee8 100644 --- a/frappe/integrations/doctype/connected_app/test_connected_app.py +++ b/frappe/integrations/doctype/connected_app/test_connected_app.py @@ -108,13 +108,8 @@ class TestConnectedApp(unittest.TestCase): session = requests.Session() - # first login of a new user on a new site fails with "401 UNAUTHORIZED" - # when anybody fixes that, the two lines below can be removed first_login = login() - self.assertEqual(first_login.status_code, 401) - - second_login = login() - self.assertEqual(second_login.status_code, 200) + self.assertEqual(first_login.status_code, 200) authorization_url = self.connected_app.initiate_web_application_flow(user=self.user_name) diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index 07db778a2d..c444964a16 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -163,10 +163,13 @@ def openid_profile(*args, **kwargs): first_name, last_name, avatar, name = frappe.db.get_value("User", frappe.session.user, ["first_name", "last_name", "user_image", "name"]) frappe_userid = frappe.db.get_value("User Social Login", {"parent":frappe.session.user, "provider": "frappe"}, "userid") request_url = urlparse(frappe.request.url) + base_url = frappe.db.get_value("Social Login Key", "frappe", "base_url") or None if avatar: if validate_url(avatar): picture = avatar + elif base_url: + picture = base_url + '/' + avatar else: picture = request_url.scheme + "://" + request_url.netloc + avatar diff --git a/frappe/model/base_document.py b/frappe/model/base_document.py index 295585665f..c1e5f01e03 100644 --- a/frappe/model/base_document.py +++ b/frappe/model/base_document.py @@ -12,11 +12,9 @@ from frappe.model.naming import set_new_name from frappe.model.utils.link_count import notify_link_count from frappe.modules import load_doctype_module from frappe.model import display_fieldtypes -from frappe.utils.password import get_decrypted_password, set_encrypted_password from frappe.utils import (cint, flt, now, cstr, strip_html, sanitize_html, sanitize_email, cast_fieldtype) from frappe.utils.html_utils import unescape_html -from bs4 import BeautifulSoup max_positive_value = { 'smallint': 2 ** 15, @@ -419,25 +417,60 @@ class BaseDocument(object): doc.db_update() def show_unique_validation_message(self, e): - # TODO: Find a better way to extract fieldname if frappe.db.db_type != 'postgres': fieldname = str(e).split("'")[-2] label = None - # unique_first_fieldname_second_fieldname is the constraint name - # created using frappe.db.add_unique - if "unique_" in fieldname: - fieldname = fieldname.split("_", 1)[1] + # MariaDB gives key_name in error. Extracting fieldname from key name + try: + fieldname = self.get_field_name_by_key_name(fieldname) + except IndexError: + pass - df = self.meta.get_field(fieldname) - if df: - label = df.label + label = self.get_label_from_fieldname(fieldname) frappe.msgprint(_("{0} must be unique").format(label or fieldname)) # this is used to preserve traceback raise frappe.UniqueValidationError(self.doctype, self.name, e) + def get_field_name_by_key_name(self, key_name): + """MariaDB stores a mapping between `key_name` and `column_name`. + This function returns the `column_name` associated with the `key_name` passed + + Args: + key_name (str): The name of the database index. + + Raises: + IndexError: If the key is not found in the table. + + Returns: + str: The column name associated with the key. + """ + return frappe.db.sql(f""" + SHOW + INDEX + FROM + `tab{self.doctype}` + WHERE + key_name=%s + AND + Non_unique=0 + """, key_name, as_dict=True)[0].get("Column_name") + + def get_label_from_fieldname(self, fieldname): + """Returns the associated label for fieldname + + Args: + fieldname (str): The fieldname in the DocType to use to pull the label. + + Returns: + str: The label associated with the fieldname, if found, otherwise `None`. + """ + df = self.meta.get_field(fieldname) + if df: + return df.label + def update_modified(self): """Update modified timestamp""" self.set("modified", now()) @@ -721,6 +754,8 @@ class BaseDocument(object): - Ignore if 'Ignore XSS Filter' is checked or fieldtype is 'Code' """ + from bs4 import BeautifulSoup + if frappe.flags.in_install: return @@ -757,6 +792,8 @@ class BaseDocument(object): def _save_passwords(self): """Save password field values in __Auth table""" + from frappe.utils.password import set_encrypted_password + if self.flags.ignore_save_passwords is True: return @@ -771,6 +808,8 @@ class BaseDocument(object): self.set(df.fieldname, '*'*len(new_password)) def get_password(self, fieldname='password', raise_exception=True): + from frappe.utils.password import get_decrypted_password + if self.get(fieldname) and not self.is_dummy_password(self.get(fieldname)): return self.get(fieldname) diff --git a/frappe/model/document.py b/frappe/model/document.py index 3ecc335cdd..d426dadd06 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -6,7 +6,6 @@ import frappe import time from frappe import _, msgprint from frappe.utils import flt, cstr, now, get_datetime_str, file_lock, date_diff -from frappe.utils.background_jobs import enqueue from frappe.model.base_document import BaseDocument, get_controller from frappe.model.naming import set_new_name from six import iteritems, string_types @@ -1269,6 +1268,8 @@ class Document(BaseDocument): # call _submit instead of submit, so you can override submit to call # run_delayed based on some action # See: Stock Reconciliation + from frappe.utils.background_jobs import enqueue + if hasattr(self, '_' + action): action = '_' + action diff --git a/frappe/patches.txt b/frappe/patches.txt index 5400c96354..d43690eac2 100644 --- a/frappe/patches.txt +++ b/frappe/patches.txt @@ -35,6 +35,7 @@ frappe.patches.v11_0.change_email_signature_fieldtype execute:frappe.reload_doc('core', 'doctype', 'activity_log') execute:frappe.reload_doc('core', 'doctype', 'deleted_document') execute:frappe.reload_doc('core', 'doctype', 'domain_settings') +frappe.patches.v13_0.rename_custom_client_script frappe.patches.v8_0.rename_page_role_to_has_role #2017-03-16 frappe.patches.v7_2.setup_custom_perms #2017-01-19 frappe.patches.v8_0.set_user_permission_for_page_and_report #2017-03-20 @@ -330,4 +331,3 @@ 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.rename_custom_client_script diff --git a/frappe/patches/v13_0/enable_custom_script.py b/frappe/patches/v13_0/enable_custom_script.py index 92284e6dcc..edc242e700 100644 --- a/frappe/patches/v13_0/enable_custom_script.py +++ b/frappe/patches/v13_0/enable_custom_script.py @@ -5,9 +5,8 @@ from __future__ import unicode_literals import frappe def execute(): - """Enable all the existing custom script""" - frappe.reload_doc("Custom", "doctype", "Custom Script") + """Enable all the existing Client script""" frappe.db.sql(""" - UPDATE `tabCustom Script` SET enabled=1 + UPDATE `tabClient Script` SET enabled=1 """) \ No newline at end of file diff --git a/frappe/public/icons/timeless/symbol-defs.svg b/frappe/public/icons/timeless/symbol-defs.svg index f5539d2bab..2b0cc8b696 100644 --- a/frappe/public/icons/timeless/symbol-defs.svg +++ b/frappe/public/icons/timeless/symbol-defs.svg @@ -268,7 +268,7 @@ + fill="var(--icon-stroke)" stroke="none"> diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 3912373bde..e033ae4c5b 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -128,7 +128,7 @@ frappe.Application = Class.extend({ } // REDESIGN-TODO: Fix preview popovers - //this.link_preview = new frappe.ui.LinkPreview(); + this.link_preview = new frappe.ui.LinkPreview(); if (!frappe.boot.developer_mode) { setInterval(function() { diff --git a/frappe/public/js/frappe/form/controls/select.js b/frappe/public/js/frappe/form/controls/select.js index ee3119aa37..026fbbf1e7 100644 --- a/frappe/public/js/frappe/form/controls/select.js +++ b/frappe/public/js/frappe/form/controls/select.js @@ -138,9 +138,9 @@ frappe.ui.form.ControlSelect = frappe.ui.form.ControlData.extend({ this.prop('disabled', false); }; - let originalVal = $.fn.val; - $.fn.val = function () { - let result = originalVal.apply(this, arguments); + let original_val = $.fn.val; + $.fn.val = function() { + let result = original_val.apply(this, arguments); if (arguments.length > 0) $(this).trigger('select-change'); return result; }; diff --git a/frappe/public/js/frappe/form/footer/form_timeline.js b/frappe/public/js/frappe/form/footer/form_timeline.js index 2559d8c01b..7b8d36d90b 100644 --- a/frappe/public/js/frappe/form/footer/form_timeline.js +++ b/frappe/public/js/frappe/form/footer/form_timeline.js @@ -358,10 +358,11 @@ class FormTimeline extends BaseTimeline { const args = { doc: this.frm.doc, frm: this.frm, - recipients: communication_doc ? communication_doc.sender : this.get_recipient(), + recipients: communication_doc && communication_doc.sender != frappe.session.user_email ? communication_doc.sender : this.get_recipient(), is_a_reply: Boolean(communication_doc), title: communication_doc ? __('Reply') : null, - last_email: communication_doc + last_email: communication_doc, + subject: communication_doc && communication_doc.subject }; if (communication_doc && reply_all) { diff --git a/frappe/public/js/frappe/ui/link_preview.js b/frappe/public/js/frappe/ui/link_preview.js index 141ca1408e..328cd23716 100644 --- a/frappe/public/js/frappe/ui/link_preview.js +++ b/frappe/public/js/frappe/ui/link_preview.js @@ -22,7 +22,6 @@ frappe.ui.LinkPreview = class { } }); this.handle_popover_hide(); - } identify_doc() { @@ -122,7 +121,7 @@ frappe.ui.LinkPreview = class { } }); - $(window).on('hashchange', () => { + frappe.router.on('change', () => { this.clear_all_popovers(); }); } @@ -142,18 +141,22 @@ frappe.ui.LinkPreview = class { let popover_content = this.get_popover_html(preview_data); this.element.popover({ container: 'body', + template: ` + + `, html: true, + sanitizeFn: (content) => content, content: popover_content, trigger: 'manual', - placement: 'top auto', - animation: false, + placement: 'top', }); - const $popover = this.element.data('bs.popover').tip(); - - $popover.addClass('link-preview-popover'); + const $popover = $(this.element.data('bs.popover').tip); $popover.toggleClass('control-field-popover', this.is_link); - this.popovers_list.push(this.element.data('bs.popover')); } @@ -167,53 +170,63 @@ frappe.ui.LinkPreview = class { this.href = this.href.replace(new RegExp(' ', 'g'), '%20'); } - let image_html = ''; - let id_html = ''; - let content_html = ''; - - if (preview_data.preview_image) { - let image_url = encodeURI(preview_data.preview_image); - image_html = ` + let popover_content =` +
- + ${this.get_image_html(preview_data)} + +
${this.get_id_html(preview_data)}
- `; - } +
+
+
+ ${this.get_content_html(preview_data)} +
+ `; - if (preview_data.preview_title != preview_data.name) { + return popover_content; + } + + get_id_html(preview_data) { + let id_html = ''; + if (preview_data.preview_title !== preview_data.name) { id_html = `${preview_data.name}`; } + return id_html; + } + + get_image_html(preview_data) { + let avatar_html = frappe.get_avatar( + "avatar-medium", + preview_data.preview_title, + preview_data.preview_image + ); + + return `
+ ${avatar_html} +
`; + } + + get_content_html(preview_data) { + let content_html = ''; + Object.keys(preview_data).forEach(key => { if (!['preview_image', 'preview_title', 'name'].includes(key)) { let value = frappe.ellipsis(preview_data[key], 280); let label = key; content_html += `
-
${__(label)}
-
${value}
+
${__(label)}
+
${value}
`; } }); - content_html = `
${content_html}
`; - let popover_content =` -
${image_html} -
-
- ${__(preview_data.preview_title)} - ${__(this.doctype)} ${id_html} -
-
-
-
-
- ${content_html} -
- `; - - return popover_content; + return `
${content_html}
`; } }; diff --git a/frappe/public/js/frappe/utils/common.js b/frappe/public/js/frappe/utils/common.js index cd3f35d830..8fec3b2611 100644 --- a/frappe/public/js/frappe/utils/common.js +++ b/frappe/public/js/frappe/utils/common.js @@ -20,27 +20,25 @@ frappe.avatar = function (user, css_class, title, image_url=null, remove_color=f title = user_info.fullname; } - return frappe.get_avatar( - user, css_class, title, image_url || user_info.image, remove_color, filterable - ); -}; - -frappe.get_avatar = function(user, css_class, title, image_url=null, remove_color, filterable) { let data_attr = ''; - - if (!css_class) { - css_class = "avatar-small"; - } - if (filterable) { css_class += " filterable"; data_attr = `data-filter="_assign,like,%${user}%"`; } + return frappe.get_avatar( + css_class, title, image_url || user_info.image, remove_color, data_attr + ); +}; + +frappe.get_avatar = function(css_class, title, image_url=null, remove_color, data_attributes) { + if (!css_class) { + css_class = "avatar-small"; + } + if (image_url) { const image = (window.cordova && image_url.indexOf('http') === -1) ? frappe.base_url + image_url : image_url; - - return ` + return ` `; @@ -55,7 +53,8 @@ frappe.get_avatar = function(user, css_class, title, image_url=null, remove_colo if (css_class === 'avatar-small' || css_class == 'avatar-xs') { abbr = abbr.substr(0, 1); } - return ` + + return `
${abbr} diff --git a/frappe/public/js/frappe/views/communication.js b/frappe/public/js/frappe/views/communication.js index 2e7ff4a812..5b4330ab6e 100755 --- a/frappe/public/js/frappe/views/communication.js +++ b/frappe/public/js/frappe/views/communication.js @@ -386,21 +386,18 @@ frappe.views.CommunicationComposer = Class.extend({ }, setup_print_language: function() { - var me = this; var doc = this.doc || cur_frm.doc; var fields = this.dialog.fields_dict; //Load default print language from doctype this.lang_code = doc.language - if (this.get_print_format().default_print_language) { - var default_print_language_code = this.get_print_format().default_print_language; - me.lang_code = default_print_language_code; - } else { - var default_print_language_code = null; + if (!this.lang_code && this.get_print_format().default_print_language) { + this.lang_code = this.get_print_format().default_print_language; } //On selection of language retrieve language code + var me = this; $(fields.language_sel.input).change(function(){ me.lang_code = this.value }) @@ -410,10 +407,8 @@ frappe.views.CommunicationComposer = Class.extend({ .empty() .add_options(frappe.get_languages()); - if (default_print_language_code) { - $(fields.language_sel.input).val(default_print_language_code); - } else { - $(fields.language_sel.input).val(doc.language); + if (this.lang_code) { + $(fields.language_sel.input).val(this.lang_code); } }, @@ -440,6 +435,7 @@ frappe.views.CommunicationComposer = Class.extend({ } }, + setup_attach: function() { var fields = this.dialog.fields_dict; var attach = $(fields.select_attachments.wrapper); diff --git a/frappe/public/js/frappe/views/treeview.js b/frappe/public/js/frappe/views/treeview.js index 93c490884e..b69053eb26 100644 --- a/frappe/public/js/frappe/views/treeview.js +++ b/frappe/public/js/frappe/views/treeview.js @@ -172,6 +172,23 @@ frappe.views.TreeView = Class.extend({ this.post_render(); }, + rebuild_tree: function() { + let me = this; + + frappe.call({ + "method": "frappe.utils.nestedset.rebuild_tree", + "args": { + 'doctype': me.doctype, + 'parent_field': "parent_"+me.doctype.toLowerCase().replace(/ /g, '_'), + }, + "callback": function(r) { + if (!r.exc) { + me.make_tree(); + } + } + }); + }, + post_render: function() { var me = this; me.opts.post_render && me.opts.post_render(me); @@ -368,7 +385,7 @@ frappe.views.TreeView = Class.extend({ }, "add"); } }, - set_menu_item: function(){ + set_menu_item: function() { var me = this; this.menu_items = [ @@ -393,6 +410,17 @@ frappe.views.TreeView = Class.extend({ }, ]; + if (frappe.user.has_role('System Manager')) { + this.menu_items.push( + { + label: __('Rebuild Tree'), + action: function() { + me.rebuild_tree(); + } + } + ); + } + if (me.opts.menu_items) { me.menu_items.push.apply(me.menu_items, me.opts.menu_items) } diff --git a/frappe/public/scss/common/global.scss b/frappe/public/scss/common/global.scss index cdb2c23558..91cc31c50d 100644 --- a/frappe/public/scss/common/global.scss +++ b/frappe/public/scss/common/global.scss @@ -162,3 +162,34 @@ html.firefox, html.safari { -webkit-transform: translate(-50%, -50%); } +.hide { + display: none !important; +} + +.btn-link { + box-shadow: none !important; + outline: none; + .icon, &:hover { + text-decoration: none !important; + } +} + +.hidden { + @extend .d-none; +} + +.margin { + margin: var(--margin-sm); +} +.margin-top { + margin-top: var(--margin-sm); +} +.margin-bottom { + margin-bottom: var(--margin-sm); +} +.margin-left { + margin-left: var(--margin-sm); +} +.margin-right { + margin-right: var(--margin-sm); +} diff --git a/frappe/public/scss/desk/global.scss b/frappe/public/scss/desk/global.scss index 7c1ddde68e..b09d9146ae 100644 --- a/frappe/public/scss/desk/global.scss +++ b/frappe/public/scss/desk/global.scss @@ -105,10 +105,6 @@ pre { color: var(--text-light) } -.hide { - display: none !important; -} - .col-xs-1 { @extend .col-1; } .col-xs-2 { @extend .col-2; } .col-xs-3 { @extend .col-3; } @@ -150,23 +146,6 @@ footer { float: right; } -// .border-${position} { -// .border-{$position} { -// border-{$position}: 1px solid var(--border-color); -// } -// } -// .border-#{$position} { -// .border-#{$position} { -// border-#{$position}: 1px solid var(--border-color); -// } -// } - -// @include border-(top); -// @include border-(bottom); -// @include border-(left); -// @include border-(right); - - img { max-width: 100%; height: auto; @@ -191,9 +170,6 @@ img { } } -.hidden { - @extend .d-none; -} .hide-control { @extend .d-none; @@ -224,10 +200,6 @@ p { font-size: var(--text-sm); } -.fill-width { - flex: 1 -} - h1 { font-size: $font-size-3xl; font-weight: 800; @@ -280,6 +252,7 @@ select.input-xs { /* popover */ .popover { background-color: var(--popover-bg); + border: 0; } .bold { @@ -386,31 +359,6 @@ kbd { cursor: default; } -.btn-link { - box-shadow: none !important; - outline: none; - .icon, &:hover { - text-decoration: none !important; - } -} - - -.margin { - margin: var(--margin-sm); -} -.margin-top { - margin-top: var(--margin-sm); -} -.margin-bottom { - margin-bottom: var(--margin-sm); -} -.margin-left { - margin-left: var(--margin-sm); -} -.margin-right { - margin-right: var(--margin-sm); -} - .standard-sidebar { font-size: var(--text-base); diff --git a/frappe/public/scss/desk/link_preview.scss b/frappe/public/scss/desk/link_preview.scss index af14c4d8ef..b2dd3ec425 100644 --- a/frappe/public/scss/desk/link_preview.scss +++ b/frappe/public/scss/desk/link_preview.scss @@ -1,48 +1,61 @@ .link-preview-popover { - border-radius: 0; max-width: 100%; + .popover-content { - padding: 0; - .preview-popover-header { - padding: var(--padding-md); + padding: var(--padding-md) 25px; - .preview-header { - display: inline-block; - vertical-align: top; - } + .preview-header { + @include flex(flex, null, center, column); + } - .preview-image { - width: 70px; - height: 70px; - object-fit: cover; - margin-right: var(--margin-sm); - border-radius: var(--border-radius); - } + .preview-image { + margin-bottom: var(--margin-sm); - .preview-name { - display: block; - } + .avatar { + width: 52px; + height: 52px; - .preview-title { - display: block; + .standard-image { + font-size: var(--text-lg); + } } } - hr { - margin: 0; + .preview-name { + font-size: var(--text-base); + font-weight: 500; } - .preview-table { - padding: var(--padding-md); - padding-bottom: var(--padding-xs); - max-width: 330px; - min-width: 200px; - overflow-wrap: break-word; + .preview-title:not(:empty) { + margin-top: var(--margin-xs); + font-size: var(--text-md); + } - .preview-field { - padding-bottom: var(--padding-sm); - .preview-label { - padding-bottom: 4px; + .popover-body { + padding: 0; + .preview-table { + padding-bottom: var(--padding-xs); + max-width: 330px; + min-width: 200px; + overflow-wrap: break-word; + + .preview-field { + + .preview-label { + padding-bottom: 4px; + } + + .preview-value { + font-weight: 500; + } + + .ql-snow .ql-editor { + min-height: 0; + } + + &:not(:last-child) { + margin-bottom: var(--margin-md); + } } } } diff --git a/frappe/public/scss/desk/variables.scss b/frappe/public/scss/desk/variables.scss index 26af534c6f..4f43f22b9d 100644 --- a/frappe/public/scss/desk/variables.scss +++ b/frappe/public/scss/desk/variables.scss @@ -98,7 +98,7 @@ $mark-padding: 0; $enable-shadows: true; $popover-border-radius: var(--border-radius); $popover-bg: var(--popover-bg); -$popover-box-shadow: var(--shadow-md); +$popover-box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1); $popover-body-padding-x: var(--padding-md); $popover-body-padding-y: var(--padding-md); $popover-border-color: var(--dark-border-color); diff --git a/frappe/public/scss/website/index.scss b/frappe/public/scss/website/index.scss index 0ddc8e8d0f..88f3b1db73 100644 --- a/frappe/public/scss/website/index.scss +++ b/frappe/public/scss/website/index.scss @@ -2,14 +2,14 @@ @import 'variables'; @import 'css_variables'; @import '~bootstrap/scss/bootstrap'; -@import "../common/mixins.scss"; -@import "../common/global.scss"; -@import "../common/icons.scss"; +@import "../common/mixins"; +@import "../common/global"; +@import "../common/icons"; @import 'base'; @import "../common/flex"; @import "../common/buttons"; @import "../common/modal"; -@import "../common/indicator.scss"; +@import "../common/indicator"; @import "../common/controls"; @import 'multilevel_dropdown'; @import 'website_image'; @@ -241,4 +241,4 @@ h5.modal-title { } .about-footer { padding-top: 1rem; -} \ No newline at end of file +} diff --git a/frappe/public/scss/website/variables.scss b/frappe/public/scss/website/variables.scss index 8fb4d0810c..fa68b57ad6 100644 --- a/frappe/public/scss/website/variables.scss +++ b/frappe/public/scss/website/variables.scss @@ -47,6 +47,10 @@ $font-sizes: ( } } +$border-radius: var(--border-radius); +$border-radius-sm: var(--border-radius-sm); +$border-radius-lg: var(--border-radius-lg); + $font-size-xs: 0.75rem !default; $font-size-sm: 0.875rem !default; $font-size-base: 1rem !default; diff --git a/frappe/sessions.py b/frappe/sessions.py index 1ca1d4ee6f..3babf1db12 100644 --- a/frappe/sessions.py +++ b/frappe/sessions.py @@ -15,7 +15,6 @@ from frappe.utils import cint, cstr import frappe.model.meta import frappe.defaults import frappe.translate -from frappe.utils.change_log import get_change_log import redis from six.moves.urllib.parse import unquote from six import text_type @@ -117,6 +116,7 @@ def clear_expired_sessions(): def get(): """get session boot info""" from frappe.boot import get_bootinfo, get_unseen_notes + from frappe.utils.change_log import get_change_log bootinfo = None if not getattr(frappe.conf,'disable_session_cache', None): diff --git a/frappe/templates/print_formats/standard_macros.html b/frappe/templates/print_formats/standard_macros.html index 9d2ca8103a..0186346840 100644 --- a/frappe/templates/print_formats/standard_macros.html +++ b/frappe/templates/print_formats/standard_macros.html @@ -140,7 +140,7 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}" style="width: 12px; height: 12px; margin-top: 5px;"> - {% elif df.fieldtype=="Image" %} + {% elif df.fieldtype in ("Image", "Attach Image") and frappe.utils.is_image(doc[doc.meta.get_field(df.fieldname).options]) %} @@ -151,7 +151,7 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}" {% elif df.fieldtype=="Signature" %} - {% elif df.fieldtype == "Attach" and doc[df.fieldname] and frappe.utils.is_image(doc[df.fieldname]) %} + {% elif df.fieldtype in ("Attach", "Attach Image") and frappe.utils.is_image(doc[df.fieldname]) %} {% elif df.fieldtype=="HTML" %} diff --git a/frappe/tests/test_auth.py b/frappe/tests/test_auth.py new file mode 100644 index 0000000000..c0f61b4863 --- /dev/null +++ b/frappe/tests/test_auth.py @@ -0,0 +1,47 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt +from __future__ import unicode_literals + +import time +import unittest +from frappe.auth import LoginAttemptTracker + +class TestLoginAttemptTracker(unittest.TestCase): + def test_account_lock(self): + """Make sure that account locks after `n consecutive failures + """ + tracker = LoginAttemptTracker(user_name='tester', max_consecutive_login_attempts=3, lock_interval=60) + # Clear the cache by setting attempt as success + tracker.add_success_attempt() + + tracker.add_failure_attempt() + self.assertTrue(tracker.is_user_allowed()) + + tracker.add_failure_attempt() + self.assertTrue(tracker.is_user_allowed()) + + tracker.add_failure_attempt() + self.assertTrue(tracker.is_user_allowed()) + + tracker.add_failure_attempt() + self.assertFalse(tracker.is_user_allowed()) + + def test_account_unlock(self): + """Make sure that locked account gets unlocked after lock_interval of time. + """ + lock_interval = 10 # In sec + tracker = LoginAttemptTracker(user_name='tester', max_consecutive_login_attempts=1, lock_interval=lock_interval) + # Clear the cache by setting attempt as success + tracker.add_success_attempt() + + tracker.add_failure_attempt() + self.assertTrue(tracker.is_user_allowed()) + + tracker.add_failure_attempt() + self.assertFalse(tracker.is_user_allowed()) + + # Sleep for lock_interval of time, so that next request con unlock the user access. + time.sleep(lock_interval) + + tracker.add_failure_attempt() + self.assertTrue(tracker.is_user_allowed()) diff --git a/frappe/translate.py b/frappe/translate.py index 9601dfe2cc..b48884f4e8 100644 --- a/frappe/translate.py +++ b/frappe/translate.py @@ -17,7 +17,6 @@ from frappe.utils import cstr import frappe, os, re, io, codecs, json from frappe.model.utils import render_include, InvalidIncludePath from frappe.utils import strip, strip_html_tags, is_html -from jinja2 import TemplateError import itertools, operator def guess_language(lang_list=None): @@ -526,6 +525,8 @@ def extract_messages_from_code(code): :param code: code from which translatable files are to be extracted :param is_py: include messages in triple quotes e.g. `_('''message''')` """ + from jinja2 import TemplateError + try: code = frappe.as_unicode(render_include(code)) except (TemplateError, ImportError, InvalidIncludePath, IOError): diff --git a/frappe/utils/__init__.py b/frappe/utils/__init__.py index 414d3b930f..1ff9da0ca9 100644 --- a/frappe/utils/__init__.py +++ b/frappe/utils/__init__.py @@ -11,12 +11,12 @@ import os import re import sys import traceback + from email.header import decode_header, make_header from email.utils import formataddr, parseaddr from gzip import GzipFile from typing import Generator, Iterable -import requests from six import string_types, text_type from six.moves.urllib.parse import quote from werkzeug.test import Client @@ -24,7 +24,6 @@ from werkzeug.test import Client import frappe # utility functions like cint, int, flt, etc. from frappe.utils.data import * -from frappe.utils.identicon import Identicon from frappe.utils.html_utils import sanitize_html @@ -170,6 +169,8 @@ def random_string(length): def has_gravatar(email): '''Returns gravatar url if user has set an avatar at gravatar.com''' + import requests + if (frappe.flags.in_import or frappe.flags.in_install or frappe.flags.in_test): @@ -193,6 +194,8 @@ def get_gravatar_url(email): return "https://secure.gravatar.com/avatar/{hash}?d=mm&s=200".format(hash=hashlib.md5(email.encode('utf-8')).hexdigest()) def get_gravatar(email): + from frappe.utils.identicon import Identicon + gravatar_url = has_gravatar(email) if not gravatar_url: @@ -457,6 +460,7 @@ def get_sites(sites_path=None): return sorted(sites) def get_request_session(max_retries=3): + import requests from urllib3.util import Retry session = requests.Session() session.mount("http://", requests.adapters.HTTPAdapter(max_retries=Retry(total=5, status_forcelist=[500]))) diff --git a/frappe/utils/data.py b/frappe/utils/data.py index 21fa609b80..37d3dde054 100644 --- a/frappe/utils/data.py +++ b/frappe/utils/data.py @@ -4,18 +4,10 @@ from __future__ import unicode_literals import frappe -from dateutil.parser._parser import ParserError import operator import json import re, datetime, math, time -import babel.dates -from babel.core import UnknownLocaleError -from dateutil import parser -from num2words import num2words -from six.moves import html_parser as HTMLParser from six.moves.urllib.parse import quote, urljoin -from html2text import html2text -from markdown2 import markdown as _markdown, MarkdownError from six import iteritems, text_type, string_types, integer_types from frappe.desk.utils import slug @@ -34,6 +26,8 @@ def getdate(string_date=None): Converts string date (yyyy-mm-dd) to datetime.date object. If no input is provided, current date is returned. """ + from dateutil import parser + from dateutil.parser._parser import ParserError if not string_date: return get_datetime().date() @@ -53,6 +47,8 @@ def getdate(string_date=None): ), title=frappe._('Invalid Date')) def get_datetime(datetime_str=None): + from dateutil import parser + if datetime_str is None: return now_datetime() @@ -74,6 +70,8 @@ def get_datetime(datetime_str=None): return parser.parse(datetime_str) def to_timedelta(time_str): + from dateutil import parser + if isinstance(time_str, string_types): t = parser.parse(time_str) return datetime.timedelta(hours=t.hour, minutes=t.minute, seconds=t.second, microseconds=t.microsecond) @@ -83,6 +81,8 @@ def to_timedelta(time_str): def add_to_date(date, years=0, months=0, weeks=0, days=0, hours=0, minutes=0, seconds=0, as_string=False, as_datetime=False): """Adds `days` to the given date""" + from dateutil import parser + from dateutil.parser._parser import ParserError from dateutil.relativedelta import relativedelta if date==None: @@ -262,6 +262,8 @@ def get_year_ending(date): return add_to_date(date, days=-1) def get_time(time_str): + from dateutil import parser + if isinstance(time_str, datetime.datetime): return time_str.time() elif isinstance(time_str, datetime.time): @@ -315,6 +317,8 @@ def format_date(string_date=None, format_string=None): * mm-dd-yyyy * dd/mm/yyyy """ + import babel.dates + from babel.core import UnknownLocaleError if not string_date: return '' @@ -343,6 +347,8 @@ def format_time(time_string=None, format_string=None): * HH:mm:ss * HH:mm """ + import babel.dates + from babel.core import UnknownLocaleError if not time_string: return '' @@ -367,6 +373,9 @@ def format_datetime(datetime_string, format_string=None): * dd-mm-yyyy HH:mm:ss * mm-dd-yyyy HH:mm """ + import babel.dates + from babel.core import UnknownLocaleError + if not datetime_string: return @@ -488,6 +497,8 @@ def get_timespan_date_range(timespan): def global_date_format(date, format="long"): """returns localized date in the form of January 1, 2012""" + import babel.dates + date = getdate(date) formatted_date = babel.dates.format_date(date, locale=(frappe.local.lang or "en").replace("-", "_"), format=format) return formatted_date @@ -550,13 +561,13 @@ def flt(s, precision=None): return num -def cint(s): +def cint(s, default=0): """Convert to integer :param s: Number in string or other numeric format. :returns: Converted number in python integer type. - Returns 0 if input can not be converted to integer. + Returns default if input can not be converted to integer. Examples: >>> cint("100") @@ -565,9 +576,10 @@ def cint(s): 0 """ - try: num = int(float(s)) - except: num = 0 - return num + try: + return int(float(s)) + except Exception: + return default def floor(s): """ @@ -846,6 +858,8 @@ def in_words(integer, in_million=True): """ Returns string in words for the given integer. """ + from num2words import num2words + locale = 'en_IN' if not in_million else frappe.local.lang integer = int(integer) try: @@ -865,7 +879,7 @@ def is_image(filepath): from mimetypes import guess_type # filepath can be https://example.com/bed.jpg?v=129 - filepath = filepath.split('?')[0] + filepath = (filepath or "").split('?')[0] return (guess_type(filepath)[0] or "").startswith("image/") def get_thumbnail_base64_for_image(src): @@ -1338,6 +1352,9 @@ def strip(val, chars=None): return (val or "").replace("\ufeff", "").replace("\u200b", "").strip(chars) def to_markdown(html): + from html2text import html2text + from six.moves import html_parser as HTMLParser + text = None try: text = html2text(html or '') @@ -1347,6 +1364,8 @@ def to_markdown(html): return text def md_to_html(markdown_text): + from markdown2 import markdown as _markdown, MarkdownError + extras = { 'fenced-code-blocks': None, 'tables': None, @@ -1361,7 +1380,7 @@ def md_to_html(markdown_text): html = None try: - html = _markdown(markdown_text or '', extras=extras) + html = UnicodeWithAttrs(_markdown(markdown_text or '', extras=extras)) except MarkdownError: pass @@ -1471,3 +1490,9 @@ def get_user_info_for_avatar(user_id): except Exception: frappe.local.message_log = [] return user_info + + +class UnicodeWithAttrs(text_type): + def __init__(self, text): + self.toc_html = text.toc_html + self.metadata = text.metadata diff --git a/frappe/utils/global_search.py b/frappe/utils/global_search.py index f605c3bf66..1c067d0146 100644 --- a/frappe/utils/global_search.py +++ b/frappe/utils/global_search.py @@ -8,7 +8,6 @@ import re import redis import json import os -from bs4 import BeautifulSoup from frappe.utils import cint, strip_html_tags from frappe.utils.html_utils import unescape_html from frappe.model.base_document import get_controller @@ -310,6 +309,7 @@ def get_routes_to_index(): def add_route_to_global_search(route): + from bs4 import BeautifulSoup from frappe.website.render import render_page from frappe.utils import set_request frappe.set_user('Guest') diff --git a/frappe/utils/html_utils.py b/frappe/utils/html_utils.py index bccdbd9441..81e5f2de3e 100644 --- a/frappe/utils/html_utils.py +++ b/frappe/utils/html_utils.py @@ -2,12 +2,12 @@ from __future__ import unicode_literals import frappe import json import re -import bleach import bleach_whitelist.bleach_whitelist as bleach_whitelist from six import string_types -from bs4 import BeautifulSoup def clean_html(html): + import bleach + if not isinstance(html, string_types): return html @@ -19,6 +19,8 @@ def clean_html(html): strip=True, strip_comments=True) def clean_email_html(html): + import bleach + if not isinstance(html, string_types): return html @@ -41,6 +43,8 @@ def clean_email_html(html): def clean_script_and_style(html): # remove script and style + from bs4 import BeautifulSoup + soup = BeautifulSoup(html, 'html5lib') for s in soup(['script', 'style']): s.decompose() @@ -53,6 +57,9 @@ def sanitize_html(html, linkify=False): Does not sanitize JSON, as it could lead to future problems """ + import bleach + from bs4 import BeautifulSoup + if not isinstance(html, string_types): return html diff --git a/frappe/utils/nestedset.py b/frappe/utils/nestedset.py index ab426d2ce4..531699db0c 100644 --- a/frappe/utils/nestedset.py +++ b/frappe/utils/nestedset.py @@ -137,10 +137,16 @@ def update_move_node(doc, parent_field): frappe.db.sql("""update `tab{0}` set lft = -lft + %s, rgt = -rgt + %s, modified=%s where lft < 0""".format(doc.doctype), (new_diff, new_diff, n)) +@frappe.whitelist() def rebuild_tree(doctype, parent_field): """ call rebuild_node for all root nodes """ + + # Check for perm if called from client-side + if frappe.request and frappe.local.form_dict.cmd == 'rebuild_tree': + frappe.only_for('System Manager') + # get all roots frappe.db.auto_commit_on_many_writes = 1 diff --git a/frappe/utils/response.py b/frappe/utils/response.py index c9123412f0..b152d69d8d 100644 --- a/frappe/utils/response.py +++ b/frappe/utils/response.py @@ -17,7 +17,6 @@ from werkzeug.local import LocalProxy from werkzeug.wsgi import wrap_file from werkzeug.wrappers import Response from werkzeug.exceptions import NotFound, Forbidden -from frappe.website.render import render from frappe.utils import cint from six import text_type from six.moves.urllib.parse import quote @@ -150,6 +149,7 @@ def json_handler(obj): def as_page(): """print web page""" + from frappe.website.render import render return render(frappe.response['route'], http_status_code=frappe.response.get("http_status_code")) def redirect(): diff --git a/frappe/website/doctype/website_settings/website_settings.py b/frappe/website/doctype/website_settings/website_settings.py index 7546b4d363..89def9bf8d 100644 --- a/frappe/website/doctype/website_settings/website_settings.py +++ b/frappe/website/doctype/website_settings/website_settings.py @@ -2,7 +2,6 @@ # MIT License. See license.txt from __future__ import unicode_literals -import requests import frappe from frappe import _ from frappe.utils import get_request_site_address, encode @@ -77,6 +76,8 @@ class WebsiteSettings(Document): frappe.clear_cache() def get_access_token(self): + import requests + google_settings = frappe.get_doc("Google Settings") if not google_settings.enable: diff --git a/frappe/website/render.py b/frappe/website/render.py index 2f8bc59d6d..eb1d3d92a1 100644 --- a/frappe/website/render.py +++ b/frappe/website/render.py @@ -10,7 +10,6 @@ import os, mimetypes, json import re import six -from bs4 import BeautifulSoup from six import iteritems from werkzeug.wrappers import Response from werkzeug.routing import Rule @@ -139,6 +138,8 @@ def build_response(path, data, http_status_code, headers=None): def add_preload_headers(response): + from bs4 import BeautifulSoup + try: preload = [] soup = BeautifulSoup(response.data, "lxml") diff --git a/frappe/website/router.py b/frappe/website/router.py index 946c83811a..bd61fc1da3 100644 --- a/frappe/website/router.py +++ b/frappe/website/router.py @@ -7,8 +7,6 @@ import io import os import re -import yaml - import frappe from frappe.model.document import get_controller from frappe.website.utils import can_cache, delete_page_cache, extract_comment_tag, extract_title @@ -283,6 +281,7 @@ def get_frontmatter(string): """ Reference: https://github.com/jonbeebe/frontmatter """ + import yaml fmatter = "" body = "" diff --git a/package.json b/package.json index 43cedc158a..5c93c24016 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "scripts": { "build": "node rollup/build.js", "production": "FRAPPE_ENV=production node rollup/build.js", - "watch": "node rollup/watch.js", + "watch": "node --max_old_space_size=1280 rollup/watch.js", "snyk-protect": "snyk protect", "prepare": "yarn run snyk-protect" }, diff --git a/yarn.lock b/yarn.lock index 85983456fb..daca81cfda 100644 --- a/yarn.lock +++ b/yarn.lock @@ -961,15 +961,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000939: - version "1.0.30001116" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001116.tgz" - integrity sha512-f2lcYnmAI5Mst9+g0nkMIznFGsArRmZ0qU+dnq8l91hymdc2J3SFbiPhOJEeDqC1vtE8nc1qNQyklzB8veJefQ== - -caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001111: - version "1.0.30001118" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001118.tgz#116a9a670e5264aec895207f5e918129174c6f62" - integrity sha512-RNKPLojZo74a0cP7jFMidQI7nvLER40HgNfgKQEJ2PFm225L0ectUungNQoK3Xk3StQcFbpBPNEvoWD59436Hg== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000939, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001111: + version "1.0.30001191" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001191.tgz" + integrity sha512-xJJqzyd+7GCJXkcoBiQ1GuxEiOBCLQ0aVW9HMekifZsAVGdj5eJ4mFB9fEhSHipq9IOk/QXFJUiIr9lZT+EsGw== caseless@~0.12.0: version "0.12.0"