Merge remote-tracking branch 'upstream/develop' into dev-import-file-error

This commit is contained in:
Rohan Bansal 2021-03-09 15:21:45 +05:30
commit e675043e9d
118 changed files with 1334 additions and 731 deletions

13
.github/workflows/semgrep.yml vendored Normal file
View file

@ -0,0 +1,13 @@
name: Semgrep
on:
pull_request: {}
jobs:
semgrep:
name: Frappe Linter
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: returntocorp/semgrep-action@v1

29
.semgrep.yml Normal file
View file

@ -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

View file

@ -7,7 +7,7 @@ addons:
- test_site_producer
mariadb: 10.3
postgresql: 9.5
chrome: stable
firefox: latest
services:
- xvfb

View file

@ -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

View file

@ -0,0 +1,36 @@
context('Control Select', () => {
before(() => {
cy.login();
cy.visit('/app/website');
});
function get_dialog_with_select() {
return cy.dialog({
title: 'Select',
fields: [{
'fieldname': 'select_control',
'fieldtype': 'Select',
'placeholder': 'Select an Option',
'options': ['', 'Option 1', 'Option 2', 'Option 2'],
}]
});
}
it('toggles placholder on clicking an option', () => {
get_dialog_with_select().as('dialog');
cy.get('.frappe-control[data-fieldname=select_control] .control-input').as('control');
cy.get('.frappe-control[data-fieldname=select_control] .control-input select').as('select');
cy.get('@control').get('.select-icon').should('exist');
cy.get('@control').get('.placeholder').should('have.css', 'display', 'block');
cy.get('@select').select('Option 1');
cy.get('@control').get('.placeholder').should('have.css', 'display', 'none');
cy.get('@select').invoke('val', '');
cy.get('@control').get('.placeholder').should('have.css', 'display', 'block');
cy.get('@dialog').then(dialog => {
dialog.hide();
});
});
});

View file

@ -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');

View file

@ -17,11 +17,15 @@ 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
# 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
@ -196,17 +200,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
@ -468,8 +475,8 @@ def get_request_header(key, default=None):
def sendmail(recipients=[], sender="", subject="No Subject", message="No Message",
as_markdown=False, delayed=True, reference_doctype=None, reference_name=None,
unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None,
attachments=None, content=None, doctype=None, name=None, reply_to=None,
unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None, add_unsubscribe_link=1,
attachments=None, content=None, doctype=None, name=None, reply_to=None, queue_separately=False,
cc=[], bcc=[], message_id=None, in_reply_to=None, send_after=None, expose_recipients=None,
send_priority=1, communication=None, retry=1, now=None, read_receipt=None, is_notification=False,
inline_images=None, template=None, args=None, header=None, print_letterhead=False, with_container=False):
@ -516,10 +523,10 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message
from frappe.email import queue
queue.send(recipients=recipients, sender=sender,
subject=subject, message=message, text_content=text_content,
reference_doctype = doctype or reference_doctype, reference_name = name or reference_name,
reference_doctype = doctype or reference_doctype, reference_name = name or reference_name, add_unsubscribe_link=add_unsubscribe_link,
unsubscribe_method=unsubscribe_method, unsubscribe_params=unsubscribe_params, unsubscribe_message=unsubscribe_message,
attachments=attachments, reply_to=reply_to, cc=cc, bcc=bcc, message_id=message_id, in_reply_to=in_reply_to,
send_after=send_after, expose_recipients=expose_recipients, send_priority=send_priority,
send_after=send_after, expose_recipients=expose_recipients, send_priority=send_priority, queue_separately=queue_separately,
communication=communication, now=now, read_receipt=read_receipt, is_notification=is_notification,
inline_images=inline_images, header=header, print_letterhead=print_letterhead, with_container=with_container)
@ -1748,12 +1755,12 @@ def parse_json(val):
def mock(type, size=1, locale='en'):
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

View file

@ -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]

View file

@ -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

View file

@ -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');
},

View file

@ -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)
frappe.cache_manager.clear_doctype_map('Assignment Rule', 'due_date_rules_for_' + rule.document_type)

View file

@ -15,7 +15,7 @@ import frappe
from frappe.utils.minify import JavascriptMinify
import click
from requests import get
import psutil
from six import iteritems, text_type
from six.moves.urllib.parse import urlparse
@ -26,6 +26,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:
@ -225,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):
@ -237,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.

View file

@ -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()

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

@ -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]

View file

@ -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: <b>Child 1 of DocType for Import</b> Row #1: Value missing for: Child Title"
self.assertEqual(frappe.parse_json(import_log[0]['messages'][0])['message'], expected_error)
expected_error = "Error: <b>Child 1 of DocType for Import</b> 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'], "<b>Child Title (Table Field 1)</b> is a mandatory field")
self.assertEqual(warnings[1]['row'], 3)
self.assertEqual(warnings[1]['message'], "<b>Child Title (Table Field 1 Again)</b> is a mandatory field")
self.assertEqual(warnings[2]['row'], 4)
self.assertEqual(warnings[2]['message'], "<b>Title</b> 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

View file

@ -117,6 +117,7 @@ def execute_event(doc: str):
frappe.only_for("System Manager")
doc = json.loads(doc)
frappe.get_doc("Scheduled Job Type", doc.get("name")).enqueue(force=True)
return doc
def run_scheduled_job(job_type: str):

View file

@ -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
}
}

View file

@ -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):
""" """

View file

@ -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
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()
@ -527,6 +531,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
@ -816,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'
@ -1147,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

View file

@ -76,6 +76,7 @@ frappe.ui.form.on("Customize Form", {
frm.trigger("setup_sortable");
}
}
localStorage["customize_doctype"] = frm.doc.doc_type;
}
});
} else {

View file

@ -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"))

View file

@ -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

View file

@ -601,8 +601,8 @@ def merge_cards_based_on_label(cards):
for card in cards:
label = card.get('label')
if label in cards_dict:
links = loads(cards_dict[label].links) + loads(card.links)
cards_dict[label].update(dict(links=dumps(links)))
links = cards_dict[label].links + card.links
cards_dict[label].update(dict(links=links))
cards_dict[label] = cards_dict.pop(label)
else:
cards_dict[label] = card

View file

@ -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)
frappe.db.set_value('Notification Settings', user, 'seen', value, update_modified=False)

View file

@ -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,

View file

@ -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:

View file

@ -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

View file

@ -19,9 +19,12 @@
"message",
"message_md",
"message_html",
"section_break_13",
"send_unsubscribe_link",
"send_attachments",
"column_break_9",
"published",
"send_webview_link",
"route",
"test_the_newsletter",
"test_email_id",
@ -160,6 +163,21 @@
"fieldtype": "Check",
"label": "Schedule Sending",
"read_only_depends_on": "eval: doc.email_sent"
},
{
"fieldname": "column_break_9",
"fieldtype": "Column Break"
},
{
"default": "0",
"depends_on": "published",
"fieldname": "send_webview_link",
"fieldtype": "Check",
"label": "Send Web View Link"
},
{
"fieldname": "section_break_13",
"fieldtype": "Section Break"
}
],
"has_web_view": 1,
@ -169,7 +187,7 @@
"is_published_field": "published",
"links": [],
"max_attachments": 3,
"modified": "2020-08-24 19:59:37.262500",
"modified": "2021-02-22 14:33:56.095380",
"modified_by": "Administrator",
"module": "Email",
"name": "Newsletter",

View file

@ -68,13 +68,17 @@ class Newsletter(WebsiteGenerator):
except IOError:
frappe.throw(_("Unable to find attachment {0}").format(file.name))
send(recipients=self.recipients, sender=sender,
subject=self.subject, message=self.get_message(),
args = {
"message": self.get_message(),
"name": self.name
}
frappe.sendmail(recipients=self.recipients, sender=sender,
subject=self.subject, message=self.get_message(), template="newsletter",
reference_doctype=self.doctype, reference_name=self.name,
add_unsubscribe_link=self.send_unsubscribe_link, attachments=attachments,
unsubscribe_method="/unsubscribe",
unsubscribe_params={"name": self.name},
send_priority=0, queue_separately=True)
send_priority=0, queue_separately=True, args=args)
if not frappe.flags.in_test:
frappe.db.auto_commit_on_many_writes = False

View file

@ -33,6 +33,9 @@ class PermissionError(Exception):
class DoesNotExistError(ValidationError):
http_status_code = 404
class PageDoesNotExistError(ValidationError):
http_status_code = 404
class NameError(Exception):
http_status_code = 409

View file

@ -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",

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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,4 @@ 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
frappe.patches.v13_0.remove_twilio_settings

View file

@ -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
""")

View file

@ -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'})

View file

@ -171,7 +171,7 @@
"fieldname": "custom_html_help",
"fieldtype": "HTML",
"label": "Custom HTML Help",
"options": "<h3>Custom CSS Help</h3>\n\n<p>Notes:</p>\n\n<ol>\n<li>All field groups (label + value) are set attributes <code>data-fieldtype</code> and <code>data-fieldname</code></li>\n<li>All values are given class <code>value</code></li>\n<li>All Section Breaks are given class <code>section-break</code></li>\n<li>All Column Breaks are given class <code>column-break</code></li>\n</ol>\n\n<h4>Examples</h4>\n\n<p>1. Left align integers</p>\n\n<pre><code>[data-fieldtype=\"Int\"] .value { text-left: left; }</code></pre>\n\n<p>1. Add border to sections except the last section</p>\n\n<pre><code>.section-break { padding: 30px 0px; border-bottom: 1px solid #eee; }\n.section-break:last-child { padding-bottom: 0px; border-bottom: 0px; }</code></pre>\n"
"options": "<h3>Custom CSS Help</h3>\n\n<p>Notes:</p>\n\n<ol>\n<li>All field groups (label + value) are set attributes <code>data-fieldtype</code> and <code>data-fieldname</code></li>\n<li>All values are given class <code>value</code></li>\n<li>All Section Breaks are given class <code>section-break</code></li>\n<li>All Column Breaks are given class <code>column-break</code></li>\n</ol>\n\n<h4>Examples</h4>\n\n<p>1. Left align integers</p>\n\n<pre><code>[data-fieldtype=\"Int\"] .value { text-align: left; }</code></pre>\n\n<p>1. Add border to sections except the last section</p>\n\n<pre><code>.section-break { padding: 30px 0px; border-bottom: 1px solid #eee; }\n.section-break:last-child { padding-bottom: 0px; border-bottom: 0px; }</code></pre>\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",

View file

@ -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() {

View file

@ -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'});
}
});

View file

@ -13,7 +13,7 @@
<span class="drag-handle">
<svg class="icon icon-xs"><use xlink:href="#icon-drag"></use></svg>
</span>
{%= __(f.label) %}
{%= __(f.label) || __(f.fieldname) %}
</div>
</div>
{% } %}

View file

@ -1,3 +0,0 @@
<svg width="6" height="8" viewBox="0 0 6 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.25 7.5L4.75 4L1.25 0.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 206 B

View file

@ -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() {

View file

@ -1,6 +1,6 @@
frappe.ui.form.ControlAttach = frappe.ui.form.ControlData.extend({
make_input: function() {
var me = this;
let me = this;
this.$input = $('<button class="btn btn-default btn-sm btn-attach">')
.html(__("Attach"))
.prependTo(me.input_area)
@ -28,7 +28,7 @@ frappe.ui.form.ControlAttach = frappe.ui.form.ControlData.extend({
this.toggle_reload_button();
},
clear_attachment: function() {
var me = this;
let me = this;
if(this.frm) {
me.parse_validate_and_set_in_model(null);
me.refresh();
@ -79,10 +79,13 @@ frappe.ui.form.ControlAttach = frappe.ui.form.ControlData.extend({
this.value = value;
if(this.value) {
this.$input.toggle(false);
if(this.value.indexOf(",")!==-1) {
var parts = this.value.split(",");
var filename = parts[0];
dataurl = parts[1];
// value can also be using this format: FILENAME,DATA_URL
// Important: We have to be careful because normal filenames may also contain ","
let file_url_parts = this.value.match(/^([^:]+),(.+):(.+)$/);
let filename;
if (file_url_parts) {
filename = file_url_parts[1];
dataurl = file_url_parts[2] + ':' + file_url_parts[3];
}
this.$value.toggle(true).find(".attached-file-link")
.html(filename || this.value)

View file

@ -2,11 +2,41 @@ frappe.ui.form.ControlSelect = frappe.ui.form.ControlData.extend({
html_element: 'select',
make_input: function() {
this._super();
this.$wrapper.find('.control-input')
.addClass('flex align-center')
.append(frappe.utils.icon('select'));
const is_xs_input = this.df.input_class
&& this.df.input_class.includes('input-xs');
this.set_icon(is_xs_input);
this.df.placeholder && this.set_placeholder(is_xs_input);
this.$input.addClass('ellipsis');
this.set_options();
},
set_icon: function(is_xs_input) {
const select_icon_html =
`<div class="select-icon ${is_xs_input ? 'xs' : ''}">
${frappe.utils.icon('select', is_xs_input ? 'xs' : 'sm')}
</div>`;
if (this.only_input) {
this.$wrapper.append(select_icon_html);
} else {
this.$wrapper.find('.control-input')
.addClass('flex align-center')
.append(select_icon_html);
}
},
set_placeholder: function(is_xs_input) {
const placeholder_html =
`<div class="placeholder ellipsis text-extra-muted ${is_xs_input ? 'xs' : ''}">
<span>${this.df.placeholder}</span>
</div>`;
if (this.only_input) {
this.$wrapper.append(placeholder_html);
} else {
this.$wrapper.find('.control-input').append(placeholder_html);
}
this.toggle_placeholder();
this.$input && this.$input.on('select-change', () => this.toggle_placeholder());
},
set_formatted_input: function(value) {
// refresh options first - (new ones??)
if(value==null) value = '';
@ -64,6 +94,10 @@ frappe.ui.form.ControlSelect = frappe.ui.form.ControlData.extend({
this.set_description(__("Please attach a file first."));
return [""];
}
},
toggle_placeholder: function() {
const input_set = Boolean(this.$input.val());
this.$wrapper.find('.placeholder').toggle(!input_set);
}
});
@ -88,6 +122,7 @@ frappe.ui.form.ControlSelect = frappe.ui.form.ControlData.extend({
label = is_label_null ? __(value) : __(v.label);
}
}
$('<option>').html(cstr(label))
.attr('value', value)
.prop('disabled', is_disabled)
@ -95,6 +130,7 @@ frappe.ui.form.ControlSelect = frappe.ui.form.ControlData.extend({
}
// select the first option
this.selectedIndex = 0;
$(this).trigger('select-change');
return $(this);
};
$.fn.set_working = function() {
@ -103,4 +139,11 @@ frappe.ui.form.ControlSelect = frappe.ui.form.ControlData.extend({
$.fn.done_working = function() {
this.prop('disabled', false);
};
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;
};
})(jQuery);

View file

@ -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) {

View file

@ -334,7 +334,7 @@ frappe.ui.form.Form = class FrappeForm {
}
}
if (action.action_type==='Server Action') {
frappe.xcall(action.action, {doc: this.doc}).then((doc) => {
frappe.xcall(action.action, {'doc': this.doc}).then((doc) => {
if (doc.doctype) {
// document is returned by the method,
// apply the changes locally and refresh

View file

@ -131,7 +131,7 @@ frappe.form.formatters = {
return repl('<a onclick="%(onclick)s">%(value)s</a>',
{onclick: docfield.link_onclick.replace(/"/g, '&quot;'), value:value});
} else if(docfield && doctype) {
if (!frappe.model.can_select(doctype) && frappe.model.can_read(doctype)) {
if (frappe.model.can_read(doctype)) {
return `<a
href="/app/${encodeURIComponent(frappe.router.slug(doctype))}/${encodeURIComponent(original_value)}"
data-doctype="${doctype}"

View file

@ -649,7 +649,7 @@ export default class Grid {
duplicate_row(d, copy_doc) {
$.each(copy_doc, function (key, value) {
if (!["creation", "modified", "modified_by", "idx", "owner",
"parent", "doctype", "name", "parentield"].includes(key)) {
"parent", "doctype", "name", "parentfield"].includes(key)) {
d[key] = value;
}
});

View file

@ -289,7 +289,7 @@ frappe.ui.form.Layout = Class.extend({
},
refresh_section_collapse: function () {
if (!this.doc) return;
if (!(this.sections && this.sections.length)) return;
for (var i = 0; i < this.sections.length; i++) {
var section = this.sections[i];

View file

@ -39,8 +39,7 @@ frappe.ui.form.AssignTo = Class.extend({
avatar_group.click(() => {
new frappe.ui.form.AssignmentDialog({
assignments: assigned_users,
frm: this.frm,
remove_action: this.remove.bind(this)
frm: this.frm
});
});
},
@ -84,7 +83,7 @@ frappe.ui.form.AssignTo = Class.extend({
frappe.ui.form.AssignToDialog = Class.extend({
init: function(opts){
init: function(opts) {
$.extend(this, opts);
this.make();
@ -214,15 +213,35 @@ frappe.ui.form.AssignmentDialog = class {
constructor(opts) {
this.frm = opts.frm;
this.assignments = opts.assignments;
this.remove_action = opts.remove_action;
this.make();
}
make() {
this.dialog = new frappe.ui.Dialog({
title: __('Assigned To'),
title: __('Assignments'),
size: 'small',
no_focus: true,
fields: [{
'label': __('Assign a user'),
'fieldname': 'user',
'fieldtype': 'Link',
'options': 'User',
'change': () => {
let value = this.dialog.get_value('user');
if (value && !this.assigning) {
this.assigning = true;
this.dialog.set_df_property('user', 'read_only', 1);
this.dialog.set_df_property('user', 'description', __('Assigning...'));
this.add_assignment(value).then(() => {
this.dialog.set_value('user', null);
}).finally(() => {
this.dialog.set_df_property('user', 'description', null);
this.dialog.set_df_property('user', 'read_only', 0);
this.assigning = false;
});
}
}
}, {
'fieldtype': 'HTML',
'fieldname': 'assignment_list'
}]
@ -236,8 +255,31 @@ frappe.ui.form.AssignmentDialog = class {
});
this.dialog.show();
}
render(assignments) {
this.frm && this.frm.assign_to.render(assignments);
}
add_assignment(assignment) {
return frappe.xcall('frappe.desk.form.assign_to.add', {
doctype: this.frm.doctype,
name: this.frm.docname,
assign_to: [assignment],
}).then((assignments) => {
this.update_assignment(assignment);
this.render(assignments);
});
}
remove_assignment(assignment) {
return frappe.xcall('frappe.desk.form.assign_to.remove', {
doctype: this.frm.doctype,
name: this.frm.docname,
assign_to: assignment,
});
}
update_assignment(assignment) {
this.assignment_list.append(this.get_assignment_row(assignment));
const in_the_list = this.assignment_list.find(`[data-user="${assignment}"]`).length;
if (!in_the_list) {
this.assignment_list.append(this.get_assignment_row(assignment));
}
}
get_assignment_row(assignment) {
let row = $(`
@ -256,10 +298,12 @@ frappe.ui.form.AssignmentDialog = class {
</span>
`);
row.find('.remove-btn').click(() => {
this.remove_action && this.remove_action(assignment);
row.remove();
this.remove_assignment(assignment).then((assignments) => {
row.remove();
this.render(assignments);
});
});
}
return row;
}
};
};

View file

@ -957,6 +957,15 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList {
)}'></span>`;
}
get_image_url(doc) {
let url = doc.image ? doc.image : doc[this.meta.image_field];
// absolute url for mobile
if (window.cordova && !frappe.utils.is_url(url)) {
url = frappe.base_url + url;
}
return url || null;
}
setup_events() {
this.setup_filterable();
this.setup_list_click();

View file

@ -156,10 +156,11 @@ frappe.views.ListViewSelect = class ListViewSelect {
items.map(item => {
if (item.name.toLowerCase() == page_name.toLowerCase()) {
placeholder = item.name;
} else {
html += `<li><a class="dropdown-item" href="${item.route}">${
item.name
}</a></li>`;
}
html += `<li><a class="dropdown-item" href="${item.route}">${
item.name
}</a></li>`;
});
}

View file

@ -621,6 +621,7 @@ $.extend(frappe.model, {
r.message || args.new_name]);
if(locals[doctype] && locals[doctype][docname])
delete locals[doctype][docname];
this.frm.reload_doc();
d.hide();
if(callback)
callback(r.message);

View file

@ -52,6 +52,8 @@ frappe.ui.Dialog = class Dialog extends frappe.ui.FieldGroup {
// make fields (if any)
super.make();
this.refresh_section_collapse();
// show footer
this.action = this.action || { primary: { }, secondary: { } };
if (this.primary_action || (this.action.primary && this.action.primary.onsubmit)) {

View file

@ -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: `
<div class="link-preview-popover popover">
<div class="arrow"></div>
<div class="popover-body popover-content">
</div>
</div>
`,
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 =`
<div class="preview-popover-header">
<div class="preview-header">
<img src="${image_url}" onerror="this.src='/assets/frappe/images/fallback-thumbnail.jpg'" class="preview-image"></img>
${this.get_image_html(preview_data)}
<div class="preview-name">
<a href=${this.href}>${__(preview_data.preview_title)}</a>
</div>
<div class="text-muted preview-title">${this.get_id_html(preview_data)}</div>
</div>
`;
}
</div>
<hr>
<div class="popover-body">
${this.get_content_html(preview_data)}
</div>
`;
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 = `<a class="text-muted" href=${this.href}>${preview_data.name}</a>`;
}
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 `<div class="preview-image">
${avatar_html}
</div>`;
}
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 += `
<div class="preview-field">
<div class='small preview-label text-muted bold'>${__(label)}</div>
<div class="small preview-value">${value}</div>
<div class="preview-label text-muted">${__(label)}</div>
<div class="preview-value">${value}</div>
</div>
`;
}
});
content_html = `<div class="preview-table">${content_html}</div>`;
let popover_content =`
<div class="preview-popover-header">${image_html}
<div class="preview-header">
<div class="preview-main">
<a class="preview-name bold" href=${this.href}>${__(preview_data.preview_title)}</a>
<span class="text-muted small">${__(this.doctype)} ${id_html}</span>
</div>
</div>
</div>
<hr>
<div class="popover-body">
${content_html}
</div>
`;
return popover_content;
return `<div class="preview-table">${content_html}</div>`;
}
};

View file

@ -200,7 +200,7 @@ frappe.msgprint = function(msg, title, is_minimizable) {
}
frappe.msg_dialog.set_primary_action(
__(data.primary_action.label || "Done"),
__(data.primary_action.label || data.primary_action_label || "Done"),
data.primary_action.action
);
} else {

View file

@ -771,6 +771,8 @@ frappe.ui.Page = Class.extend({
df.placeholder = df.label;
}
df.input_class = 'input-xs';
var f = frappe.ui.form.make_control({
df: df,
parent: parent || this.page_form,
@ -792,7 +794,7 @@ frappe.ui.Page = Class.extend({
// hidden fields dont have $input
if (!f.$input) f.make_input();
f.$input.addClass("input-xs").attr("placeholder", __(df.label));
f.$input.attr("placeholder", __(df.label));
if(df.fieldtype==="Check") {
$(f.wrapper).find(":first-child")

View file

@ -14,7 +14,7 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
}
refresh() {
this.current_theme = document.body.dataset.theme;
this.current_theme = document.documentElement.getAttribute("data-theme") || "light";
this.fetch_themes().then(() => {
this.render();
});
@ -45,7 +45,6 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
});
}
get_preview_html(theme) {
const preview = $(`<div class="${this.current_theme == theme.name ? "selected" : "" }">
<div data-theme=${theme.name}>
@ -69,15 +68,9 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
</div>
</div>`);
// preview.on('mouseover', () => {
// this.toggle_theme(theme.name, true)
// })
// preview.on('mouseleave', () => {
// this.toggle_theme(this.current_theme, true)
// })
preview.on('click', () => {
if (this.current_theme === theme.name) return;
this.themes.forEach((th) => {
th.$html.removeClass("selected");
});
@ -89,19 +82,15 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
return preview;
}
toggle_theme(theme, preview=false) {
if (!preview) {
document.body.dataset.theme = theme.toLowerCase();
frappe.show_alert("Theme Changed", 3);
toggle_theme(theme) {
this.current_theme = theme.toLowerCase();
document.documentElement.setAttribute("data-theme", this.current_theme);
frappe.show_alert("Theme Changed", 3);
frappe.call('frappe.core.doctype.user.user.switch_theme', {
theme: toTitle(theme)
});
} else {
document.body.dataset.theme = theme.toLowerCase();
}
frappe.xcall("frappe.core.doctype.user.user.switch_theme", {
theme: toTitle(theme)
});
}
show() {
this.dialog.show();
}
@ -109,4 +98,4 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
hide() {
this.dialog.hide();
}
};
};

View file

@ -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 `<span class="avatar ${css_class}" title="${title}" ${data_attr}>
return `<span class="avatar ${css_class}" title="${title}" ${data_attributes}>
<span class="avatar-frame" style='background-image: url("${image}")'
title="${title}"></span>
</span>`;
@ -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 `<span class="avatar ${css_class}" title="${title}" ${data_attr}>
return `<span class="avatar ${css_class}" title="${title}" ${data_attributes}>
<div class="avatar-frame standard-image"
style="${style}">
${abbr}

View file

@ -203,7 +203,9 @@ frappe.views.CommunicationComposer = Class.extend({
if(this.dialog.fields_dict.sender) {
this.dialog.fields_dict.sender.set_value(this.sender || '');
}
this.dialog.fields_dict.subject.set_value(this.subject || '');
this.dialog.fields_dict.subject.set_value(
frappe.utils.html2text(this.subject) || ''
);
this.setup_earlier_reply();
},
@ -386,21 +388,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 +409,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 +437,7 @@ frappe.views.CommunicationComposer = Class.extend({
}
},
setup_attach: function() {
var fields = this.dialog.fields_dict;
var attach = $(fields.select_attachments.wrapper);
@ -606,7 +604,7 @@ frappe.views.CommunicationComposer = Class.extend({
delete_saved_draft() {
if (this.dialog) {
localforage.getItem(this.frm.doctype + this.frm.docname).catch(e => {
localforage.removeItem(this.frm.doctype + this.frm.docname).catch(e => {
if (e) {
// silently fail
console.log(e); // eslint-disable-line

View file

@ -85,7 +85,7 @@ frappe.views.FormFactory = class FormFactory extends frappe.views.Factory {
if (name && name.substr(0, 3) === 'new') {
this.render_new_doc(doctype, name, doctype_layout);
} else {
frappe.show_not_found(route);
frappe.show_not_found();
}
return;
}

View file

@ -142,20 +142,6 @@ frappe.views.ImageView = class ImageView extends frappe.views.ListView {
`;
}
get_image_url(data) {
var url;
url = data.image ? data.image : data[this.meta.image_field];
// absolute url for mobile
if (window.cordova && !frappe.utils.is_url(url)) {
url = frappe.base_url + url;
}
if (url) {
return url;
}
return null;
}
get_attached_images() {
return frappe
.call({

View file

@ -301,7 +301,7 @@ frappe.provide("frappe.views");
function init() {
fluxify.doAction('init', opts);
store.on('change:columns', make_columns);
store.off('change:columns').on('change:columns', make_columns);
prepare();
store.on('change:cur_list', setup_restore_columns);
store.on('change:columns', setup_restore_columns);
@ -605,9 +605,10 @@ frappe.provide("frappe.views");
function make_dom() {
var opts = {
name: card.name,
title: remove_img_tags(card.title),
title: frappe.utils.html2text(card.title),
disable_click: card._disable_click ? 'disable-click' : '',
creation: card.creation,
image_url: cur_list.get_image_url(card),
};
self.$card = $(frappe.render_template('kanban_card', opts))
.appendTo(wrapper);
@ -705,6 +706,7 @@ frappe.provide("frappe.views");
title: card[state.card_meta.title_field.fieldname],
creation: moment(card.creation).format('MMM DD, YYYY'),
_liked_by: card._liked_by,
image: card[cur_list.meta.image_field],
tags: card._user_tags,
column: card[state.board.field_name],
assigned_list: card.assigned_list || assigned_list,

View file

@ -1,15 +1,22 @@
<div class="kanban-card-wrapper {{ disable_click }}" data-name="{{escape(name)}}">
<a class="kanban-card-redirect" href="#">
<div class="kanban-card content">
<div class="kanban-title-area">
<div class="kanban-card-title" title="{{title}}">
{{ title }}
</div>
<div class="kanban-card-creation">
{{ creation }}
</div>
{% if(image_url) { %}
<div class="kanban-image">
<img src="{{image_url}}" alt="{{title}}">
</div>
<div class="kanban-card-meta">
{% } %}
<div class="kanban-card-body">
<div class="kanban-title-area">
<div class="kanban-card-title ellipsis" title="{{title}}">
{{ title }}
</div>
<div class="kanban-card-creation">
{{ creation }}
</div>
</div>
<div class="kanban-card-meta">
</div>
</div>
</div>
</a>

View file

@ -2,7 +2,7 @@
<div class="kanban-column-header">
<span class="kanban-column-title">
<span class="indicator-pill {{indicator}}"></span>
<span class="kanban-title">{{ __(title) }}</span>
<span class="kanban-title ellipsis" title="{{title}}">{{ __(title) }}</span>
</span>
<div class="column-options dropdown pull-right">
<a data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">

View file

@ -73,7 +73,6 @@ frappe.views.Page = class Page {
return;
}
this.wrapper = frappe.container.add_page(this.name);
this.wrapper.label = this.pagedoc.title || this.pagedoc.name;
this.wrapper.page_name = this.pagedoc.name;
// set content, script and style

View file

@ -1203,11 +1203,11 @@ frappe.views.ReportView = class ReportView extends frappe.views.ListView {
// Rerender the reports dropdown,
// so that this report is included in the dropdown as well.
frappe.boot.user.all_reports[r.message] = {
ref_doctype: "Item",
ref_doctype: this.doctype,
report_type: "Report Builder",
title: r.message,
};
this.list_sidebar.setup_reports();
frappe.set_route('List', this.doctype, 'Report', r.message);
return;
}

View file

@ -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)
}

View file

@ -78,8 +78,9 @@
.btn.btn-primary {
background-color: var(--primary-color);
color: var(--white);
white-space: nowrap;
--icon-stroke: white;
--icon-stroke: currentColor;
--icon-fill-bg: var(--primary-color);
}

View file

@ -254,7 +254,7 @@ textarea.form-control {
overflow-wrap: break-word;
}
.frappe-control[data-fieldtype="Data"] .control-input {
.frappe-control[data-fieldtype="Data"] .control-input, .control-value {
position: relative;
}

View file

@ -4,6 +4,14 @@
font-family: inherit;
z-index: 9999 !important;
background: var(--fg-color);
color: var(--text-color);
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
&--nav {
border-bottom: 1px solid var(--border-color);
}
&--time-current-hours, &--time-current-minutes, &--time-current-seconds {
font-family: inherit;
@ -45,6 +53,10 @@
}
&--time, &--buttons {
border-top: 1px solid var(--border-color);
}
&--time-row {
background-image: linear-gradient(to right, #0089FF, #0089FF);
background-repeat: no-repeat;

View file

@ -8,7 +8,6 @@
--checkbox-right-margin: 8px;
.label-area {
line-height: 1;
font-size: var(--text-sm);
}
@ -84,7 +83,8 @@ html.firefox, html.safari {
@include card();
}
.frappe-control[data-fieldtype="Select"] .control-input {
.frappe-control[data-fieldtype="Select"] .control-input,
.frappe-control[data-fieldtype="Select"].form-group {
position: relative;
select {
@ -94,14 +94,37 @@ html.firefox, html.safari {
-webkit-appearance: none;
}
.icon {
.select-icon {
padding-left: inherit;
padding-right: inherit;
position: absolute;
top: 8px;
height: 15px;
right: 12px;
pointer-events: none;
use {
stroke: var(--text-muted);
top: 7px;
right: 12px;
&.xs {
top: 3px;
right: 10px;
}
.icon {
use {
stroke: var(--text-muted);
}
}
}
.placeholder {
padding-left: inherit;
padding-right: inherit;
position: absolute;
pointer-events: none;
top: 7px;
left: 12px;
max-width: 80%;
&.xs {
top: 3px;
}
}
}
@ -138,3 +161,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);
}

View file

@ -37,6 +37,38 @@
background-color: var(--bg-light-gray);
}
@mixin broken-img(
$content: null,
$height: 100%,
$top: 0,
$left: 0,
$background-color: var(--bg-color),
$border-radius: var(--border-radius),
) {
@if $content {
img:after {
content: url($content);
}
} @else {
img:after {
content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='lightgrey' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-image'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
}
}
img[alt]:after {
height: $height;
top: $top;
left: $left;
background-color: $background-color;
border-radius: $border-radius;
width: 100%;
position: absolute;
@include flex();
z-index: 1;
}
}
// @mixin img-foreground() {
// content: "\f1c5";
// display: block;

View file

@ -12,7 +12,7 @@
font-size: var(--text-md);
margin-right: 10px;
&:before {
content: url('/assets/frappe/icons/timeless/icon-right-arrow.svg');
content: var(--right-arrow-svg);
display: inline-block;
margin-right: 10px;
}

View file

@ -50,6 +50,7 @@ $input-height: 28px !default;
// input
--input-height: #{$input-height};
--input-disabled-bg: var(--gray-200);
// timeline
--timeline-item-icon-size: 34px;
@ -60,4 +61,6 @@ $input-height: 28px !default;
// skeleton
--skeleton-bg: var(--gray-100);
--right-arrow-svg: url("data: image/svg+xml;utf8, <svg width='6' height='8' viewBox='0 0 6 8' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M1.25 7.5L4.75 4L1.25 0.5' stroke='%231F272E' stroke-linecap='round' stroke-linejoin='round'/></svg>");
}

View file

@ -72,6 +72,9 @@
--highlight-color: var(--gray-700);
--yellow-highlight-color: var(--yellow-700);
// input
--input-disabled-bg: none;
.frappe-card {
.btn-default {
background-color: var(--bg-color);
@ -142,4 +145,6 @@
// skeleton
--skeleton-bg: var(--gray-800);
--right-arrow-svg: url("data: image/svg+xml;utf8, <svg width='6' height='8' viewBox='0 0 6 8' fill='none' xmlns='http://www.w3.org/2000/svg'><path d='M1.25 7.5L4.75 4L1.25 0.5' stroke='white' stroke-linecap='round' stroke-linejoin='round'/></svg>");
}

View file

@ -98,21 +98,10 @@
position: relative;
}
img:after {
content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='lightgrey' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-image'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
}
img[alt]:after {
height: 70px;
@include flex();
position: absolute;
z-index: 1;
top: -15px;
left: 0px;
width: 100%;
background-color: var(--bg-color);
border-radius: var(--border-radius);
}
@include broken-img(
$height: 70px,
$top: -15px,
);
}
}

View file

@ -7,7 +7,7 @@
--dt-text-color: var(--text-light);
--dt-text-light: var(--bg-color);
--dt-spacer-1: 0.25rem;
--dt-spacer-2: 0.5rem;
--dt-spacer-2: var(--padding-xs);
--dt-spacer-3: 1rem;
--dt-border-radius: var(--border-radius);
--dt-cell-bg: var(--fg-color);
@ -26,6 +26,16 @@
border-radius: 0px;
border: none;
background-color: transparent;
height: 100%;
&:focus {
box-shadow: none;
}
&[data-fieldtype="Select"] .select-icon {
top: 5px;
right: 10px;
}
}
.dt-header {

View file

@ -3,19 +3,6 @@ html {
background-color: var(--bg-color);
}
// transition
* {
transition: background-color 0.5s, background 0.5s;
}
a,
.badge {
transition: 0.2s;
}
.btn {
transition: background-color 0.2s, background 0.2s;
}
body {
@ -102,11 +89,8 @@ a.badge-hover {
}
pre {
color: var(--text-light)
}
.hide {
display: none !important;
color: var(--text-light);
white-space: pre-wrap;
}
.col-xs-1 { @extend .col-1; }
@ -150,23 +134,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 +158,6 @@ img {
}
}
.hidden {
@extend .d-none;
}
.hide-control {
@extend .d-none;
@ -224,10 +188,6 @@ p {
font-size: var(--text-sm);
}
.fill-width {
flex: 1
}
h1 {
font-size: $font-size-3xl;
font-weight: 800;
@ -280,6 +240,7 @@ select.input-xs {
/* popover */
.popover {
background-color: var(--popover-bg);
border: 0;
}
.bold {
@ -386,31 +347,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);

View file

@ -154,20 +154,10 @@
width: 100%;
}
img:after {
content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='lightgrey' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-image'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
}
img[alt]:after {
height: 175px;
@include flex();
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
background-color: var(--bg-color);
}
@include broken-img(
$height: 175px,
$border-radius: 0
);
}
.image-title {

View file

@ -1,5 +1,4 @@
@import "variables";
@import "css_variables";
@import "../common/mixins.scss";
@import "../common/global.scss";
@import "../common/icons.scss";
@ -37,7 +36,7 @@
@import "calendar";
@import "dashboard_view";
@import "tree";
@import "controls";
@import "../common/controls";
@import "data_import";
@import "driver";
@import "role_editor";

View file

@ -117,11 +117,13 @@
font-size: var(--text-lg);
color: var(--text-color);
font-weight: normal;
padding-right: var(--padding-xs);
max-width: 100%;
// margin-left: 10px;
.kanban-title {
font-size: var(--text-lg);
margin-left: 10px;
margin-left: var(--margin-sm);
font-weight: var(--text-bold, 600);
}
}
@ -151,13 +153,15 @@
.kanban-card {
@include flex(flex, space-between, null, column);
@include card(
$padding: var(--padding-sm) var(--padding-md),
$background-color: var(--kanban-card-bg)
);
margin-top: var(--margin-sm);
min-height: 100px;
@include card(
$padding: 0,
$background-color: var(--kanban-card-bg)
);
.kanban-card-body {
padding: var(--padding-sm);
}
}
}
@ -209,6 +213,27 @@
}
}
.kanban-image {
height: 125px;
img {
border-radius: var(--border-radius) var(--border-radius) 0 0;
object-position: top;
object-fit: cover;
margin: 0 auto;
height: 100%;
width: 100%;
min-width: 100%;
color: transparent;
position: relative;
}
@include broken-img(
$height: 125px,
$top: -4px,
)
}
.kanban-card-edit {
position: absolute;
right: 10px;
@ -287,6 +312,7 @@
height: 22px;
width: auto;
padding: 2px 8px;
margin-bottom: var(--margin-xs);
margin-right: var(--margin-xs);
}
}

View file

@ -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);
}
}
}
}

View file

@ -44,20 +44,6 @@
display: none;
}
.frappe-control[data-fieldtype="Select"] .control-input {
select {
height: auto;
padding: var(--padding-xs) var(--padding-sm);
text-align: left;
}
.icon {
width: 13px;
top: 7px;
right: 5px;
}
}
.label-area {
white-space: nowrap;
}

View file

@ -60,11 +60,12 @@ $link-color: var(--text-color);
// input
$input-bg: var(--control-bg);
$input-placeholder-color: var(--gray-500);
$input-disabled-bg: var(--gray-200);
$input-disabled-bg: var(--input-disabled-bg);
$input-color: var(--text-color);
$input-box-shadow: none;
$input-focus-border-color: var(--gray-500);
$input-border-radius: var(--border-radius);
$input-btn-focus-width: 2px;
// dropdown
$dropdown-color: var(--text-color);
@ -94,11 +95,15 @@ $btn-active-box-shadow: var(--shadow-inset);
$mark-bg: #FDF9AF;
$mark-padding: 0;
// transitions
$btn-transition: none;
$input-transition: none;
// popover
$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);
@ -134,9 +139,9 @@ $grid-breakpoints: (
2xl: 1440px
) !default;
@import 'dark';
@import 'typography';
@import '~bootstrap/scss/functions';
@import '~bootstrap/scss/variables';
@import "~bootstrap/scss/mixins";
@import 'css_variables';
@import 'dark';

View file

@ -1,14 +1,17 @@
@import '~quill/dist/quill.core';
@import '~quill/dist/quill.snow.css';
@import '~quill/dist/quill.bubble.css';
@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';
@import 'website_avatar';
@ -239,4 +242,4 @@ h5.modal-title {
}
.about-footer {
padding-top: 1rem;
}
}

View file

@ -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;
@ -127,5 +131,6 @@ $spacers: (
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins";
@import 'css_variables';
$code-color: $purple;

View file

@ -4,36 +4,10 @@ img {
position: relative;
}
// fallback for broken images
// img:after {
// content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='lightgrey' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-image'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
// display: flex;
// justify-content: center;
// align-items: center;
// position: absolute;
// z-index: 1;
// top: 0;
// left: 0;
// width: 100%;
// height: 100%;
// background-color: $light;
// padding: 50% 0;
// }
img:after {
content: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='lightgrey' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='feather feather-image'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
}
img[alt]:after {
height: 175px;
@include flex();
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
background-color: var(--bg-color);
}
@include broken-img(
$height: 175px,
$border-radius: 0,
);
.website-image-placeholder {
display: flex;

View file

@ -5,10 +5,14 @@
from __future__ import unicode_literals
from datetime import datetime
from functools import wraps
from typing import Union, Callable
from werkzeug.wrappers import Response
import frappe
from frappe import _
from frappe.utils import cint
from werkzeug.wrappers import Response
def apply():
@ -79,3 +83,43 @@ class RateLimiter:
def respond(self):
if self.rejected:
return Response(_("Too Many Requests"), status=429)
def rate_limit(key: str, limit: Union[int, Callable] = 5, seconds: int= 24*60*60, methods: Union[str, list]='ALL'):
"""Decorator to rate limit an endpoint.
This will limit Number of requests per endpoint to `limit` within `seconds`.
Uses redis cache to track request counts.
:param key: Key is used to identify the requests uniqueness
:param limit: Maximum number of requests to allow with in window time
:type limit: Callable or Integer
:param seconds: window time to allow requests
:param methods: Limit the validation for these methods.
`ALL` is a wildcard that applies rate limit on all methods.
:type methods: string or list or tuple
:returns: a decorator function that limit the number of requests per endpoint
"""
def ratelimit_decorator(fun):
@wraps(fun)
def wrapper(*args, **kwargs):
# Do not apply rate limits if method is not opted to check
if methods != 'ALL' and frappe.request.method.upper() not in methods:
return frappe.call(fun, **frappe.form_dict)
_limit = limit() if callable(limit) else limit
identity = frappe.form_dict[key]
cache_key = f"rl:{frappe.form_dict.cmd}:{identity}"
value = frappe.cache().get_value(cache_key, expires=True) or 0
if not value:
frappe.cache().set_value(cache_key, 0, expires_in_sec=seconds)
value = frappe.cache().incrby(cache_key, 1)
if value > _limit:
frappe.throw(_("You hit the rate limit because of too many requests. Please try after sometime."))
return frappe.call(fun, **frappe.form_dict)
return wrapper
return ratelimit_decorator

View file

@ -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):

View file

@ -319,7 +319,10 @@ def send_summary(timespan):
from_date = getdate(from_date)
to_date = getdate()
all_users = [user.email for user in get_enabled_system_users()]
# select only those users that have energy point email notifications enabled
all_users = [user.email for user in get_enabled_system_users() if
is_email_notifications_enabled_for_type(user.name, 'Energy Point')]
frappe.sendmail(
subject = '{} energy points summary'.format(timespan),

View file

@ -0,0 +1,10 @@
<div style="text-align: justify;">
<div style="width: 600px; margin: 10px auto;">
{{ message }}
</div>
</div>
<div style="font-size: 12px; line-height: 20px;">
<div>
Open in <a style="color: #687178; text-decoration: underline;" href="/newsletters/{{ name }}" target="_blank">web</a>
</div>
</div>

View file

@ -140,7 +140,7 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}"
style="width: 12px; height: 12px; margin-top: 5px;">
<path d="M2 9.66667L5.33333 13L14 3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% elif df.fieldtype=="Image" %}
{% elif df.fieldtype in ("Image", "Attach Image") and frappe.utils.is_image(doc[doc.meta.get_field(df.fieldname).options]) %}
<img src="{{ doc[doc.meta.get_field(df.fieldname).options] }}"
class="img-responsive"
{%- if df.print_width %} style="width: {{ get_width(df) }};"{% endif %}>
@ -151,7 +151,7 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}"
{% elif df.fieldtype=="Signature" %}
<img src="{{ doc[df.fieldname] }}" class="signature-img img-responsive"
{%- if df.print_width %} style="width: {{ get_width(df) }};"{% endif %}>
{% 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]) %}
<img src="{{ doc[df.fieldname] }}" class="img-responsive"
{%- if df.print_width %} style="width: {{ get_width(df) }};"{% endif %}>
{% elif df.fieldtype=="HTML" %}
@ -165,7 +165,12 @@ data-fieldname="{{ df.fieldname }}" data-fieldtype="{{ df.fieldtype }}"
{%- endmacro %}
{% macro get_width(df) -%}
{%- if df.print_width -%}{{ (df.print_width|str).replace("px", "") }}px
{%- if df.print_width -%}
{%- if df.print_width.endswith("%") -%}
{{ df.print_width }}
{%- else -%}
{{ df.print_width.replace("px", "") }}px
{%- endif -%}
{%- elif df.fieldtype in ("Int", "Check", "Float", "Currency") -%}{{ 80 }}px
{%- else -%}{{ 150 }}px{% endif -%}
{%- endmacro %}

Some files were not shown because too many files have changed in this diff Show more