Merge branch 'develop' of https://github.com/frappe/frappe into python-distributed-testing

This commit is contained in:
Suraj Shetty 2021-05-08 11:27:42 +05:30
commit 85f6f47edc
122 changed files with 1927 additions and 1002 deletions

View file

@ -80,6 +80,7 @@
"validate_email": true,
"validate_name": true,
"validate_phone": true,
"validate_url": true,
"get_number_format": true,
"format_number": true,
"format_currency": true,

View file

@ -42,7 +42,7 @@ rules:
- id: frappe-translation-python-splitting
pattern-either:
- pattern: _(...) + ... + _(...)
- pattern: _(...) + _(...)
- pattern: _("..." + "...")
- pattern-regex: '_\([^\)]*\\\s*' # lines broken by `\`
- pattern-regex: '_\(\s*\n' # line breaks allowed by python for using ( )

View file

@ -0,0 +1,65 @@
export default {
name: 'Validation Test',
custom: 1,
actions: [],
creation: '2019-03-15 06:29:07.215072',
doctype: 'DocType',
editable_grid: 1,
engine: 'InnoDB',
fields: [
{
fieldname: 'email',
fieldtype: 'Data',
label: 'Email',
options: 'Email'
},
{
fieldname: 'URL',
fieldtype: 'Data',
label: 'URL',
options: 'URL'
},
{
fieldname: 'Phone',
fieldtype: 'Data',
label: 'Phone',
options: 'Phone'
},
{
fieldname: 'person_name',
fieldtype: 'Data',
label: 'Person Name',
options: 'Name'
},
{
fieldname: 'read_only_url',
fieldtype: 'Data',
label: 'Read Only URL',
options: 'URL',
read_only: '1',
default: 'https://frappe.io'
}
],
issingle: 1,
links: [],
modified: '2021-04-19 14:40:53.127615',
modified_by: 'Administrator',
module: 'Custom',
owner: 'Administrator',
permissions: [
{
create: 1,
delete: 1,
email: 1,
print: 1,
read: 1,
role: 'System Manager',
share: 1,
write: 1
}
],
quick_entry: 1,
sort_field: 'modified',
sort_order: 'ASC',
track_changes: 1
};

View file

@ -0,0 +1,43 @@
import data_field_validation_doctype from '../fixtures/data_field_validation_doctype';
const doctype_name = data_field_validation_doctype.name;
context('Data Field Input Validation in New Form', () => {
before(() => {
cy.login();
cy.visit('/app/website');
return cy.insert_doc('DocType', data_field_validation_doctype, true);
});
function validateField(fieldname, invalid_value, valid_value) {
// Invalid, should have has-error class
cy.get_field(fieldname).clear().type(invalid_value).blur();
cy.get(`.frappe-control[data-fieldname="${fieldname}"]`).should('have.class', 'has-error');
// Valid value, should not have has-error class
cy.get_field(fieldname).clear().type(valid_value);
cy.get(`.frappe-control[data-fieldname="${fieldname}"]`).should('not.have.class', 'has-error');
}
describe('Data Field Options', () => {
it('should validate email address', () => {
cy.new_form(doctype_name);
validateField('email', 'captian', 'hello@test.com');
});
it('should validate URL', () => {
validateField('url', 'jkl', 'https://frappe.io');
validateField('url', 'abcd.com', 'http://google.com/home');
validateField('url', '&&http://google.uae', 'gopher://frappe.io');
validateField('url', 'ftt2:://google.in?q=news', 'ftps2://frappe.io/__/#home');
validateField('url', 'ftt2://', 'ntps://localhost'); // For intranet URLs
});
it('should validate phone number', () => {
validateField('phone', 'america', '89787878');
});
it('should validate name', () => {
validateField('person_name', ' 777Hello', 'James Bond');
});
});
});

View file

@ -0,0 +1,43 @@
import data_field_validation_doctype from '../fixtures/data_field_validation_doctype';
const doctype_name = data_field_validation_doctype.name;
context('URL Data Field Input', () => {
before(() => {
cy.login();
cy.visit('/app/website');
return cy.insert_doc('DocType', data_field_validation_doctype, true);
});
describe('URL Data Field Input ', () => {
it('should not show URL link button without focus', () => {
cy.new_form(doctype_name);
cy.get_field('url').clear().type('https://frappe.io');
cy.get_field('url').blur().wait(500);
cy.get('.link-btn').should('not.be.visible');
});
it('should show URL link button on focus', () => {
cy.get_field('url').focus().wait(500);
cy.get('.link-btn').should('be.visible');
});
it('should not show URL link button for invalid URL', () => {
cy.get_field('url').clear().type('fuzzbuzz');
cy.get('.link-btn').should('not.be.visible');
});
it('should have valid URL link with target _blank', () => {
cy.get_field('url').clear().type('https://frappe.io');
cy.get('.link-btn .btn-open').should('have.attr', 'href', 'https://frappe.io');
cy.get('.link-btn .btn-open').should('have.attr', 'target', '_blank');
});
it('should inject anchor tag in read-only URL data field', () => {
cy.get('[data-fieldname="read_only_url"]')
.find('a')
.should('have.attr', 'target', '_blank');
});
});
});

View file

@ -10,11 +10,9 @@ be used to build database driven apps.
Read the documentation: https://frappeframework.com/docs
"""
from __future__ import unicode_literals, print_function
from six import iteritems, binary_type, text_type, string_types, PY2
from werkzeug.local import Local, release_local
import os, sys, importlib, inspect, json
import os, sys, importlib, inspect, json, warnings
import typing
from past.builtins import cmp
import click
@ -27,19 +25,14 @@ 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
if PY2:
reload(sys)
sys.setdefaultencoding("utf-8")
__version__ = '14.0.0-dev'
__title__ = "Frappe Framework"
local = Local()
controllers = {}
warnings.simplefilter('always', DeprecationWarning)
warnings.simplefilter('always', PendingDeprecationWarning)
class _dict(dict):
"""dict like object that exposes keys as attributes"""
@ -97,14 +90,14 @@ def _(msg, lang=None, context=None):
def as_unicode(text, encoding='utf-8'):
'''Convert to unicode if required'''
if isinstance(text, text_type):
if isinstance(text, str):
return text
elif text==None:
return ''
elif isinstance(text, binary_type):
return text_type(text, encoding)
elif isinstance(text, bytes):
return str(text, encoding)
else:
return text_type(text)
return str(text)
def get_lang_dict(fortype, name=None):
"""Returns the translated language dict for the given type and name.
@ -597,7 +590,7 @@ def is_whitelisted(method):
# strictly sanitize form_dict
# escapes html characters like <> except for predefined tags like a, b, ul etc.
for key, value in form_dict.items():
if isinstance(value, string_types):
if isinstance(value, str):
form_dict[key] = sanitize_html(value)
def read_only():
@ -721,7 +714,7 @@ def has_website_permission(doc=None, ptype='read', user=None, verbose=False, doc
user = session.user
if doc:
if isinstance(doc, string_types):
if isinstance(doc, str):
doc = get_doc(doctype, doc)
doctype = doc.doctype
@ -790,7 +783,7 @@ def set_value(doctype, docname, fieldname, value=None):
return frappe.client.set_value(doctype, docname, fieldname, value)
def get_cached_doc(*args, **kwargs):
if args and len(args) > 1 and isinstance(args[1], text_type):
if args and len(args) > 1 and isinstance(args[1], str):
key = get_document_cache_key(args[0], args[1])
# local cache
doc = local.document_cache.get(key)
@ -821,7 +814,7 @@ def clear_document_cache(doctype, name):
def get_cached_value(doctype, name, fieldname, as_dict=False):
doc = get_cached_doc(doctype, name)
if isinstance(fieldname, string_types):
if isinstance(fieldname, str):
if as_dict:
throw('Cannot make dict for single fieldname')
return doc.get(fieldname)
@ -1027,7 +1020,7 @@ def get_doc_hooks():
if not hasattr(local, 'doc_events_hooks'):
hooks = get_hooks('doc_events', {})
out = {}
for key, value in iteritems(hooks):
for key, value in hooks.items():
if isinstance(key, tuple):
for doctype in key:
append_hook(out, doctype, value)
@ -1144,7 +1137,7 @@ def get_file_json(path):
def read_file(path, raise_not_found=False):
"""Open a file and return its content as Unicode."""
if isinstance(path, text_type):
if isinstance(path, str):
path = path.encode("utf-8")
if os.path.exists(path):
@ -1167,7 +1160,7 @@ def get_attr(method_string):
def call(fn, *args, **kwargs):
"""Call a function and match arguments."""
if isinstance(fn, string_types):
if isinstance(fn, str):
fn = get_attr(fn)
newargs = get_newargs(fn, kwargs)
@ -1178,13 +1171,9 @@ def get_newargs(fn, kwargs):
if hasattr(fn, 'fnargs'):
fnargs = fn.fnargs
else:
try:
fnargs, varargs, varkw, defaults = inspect.getargspec(fn)
except ValueError:
fnargs = inspect.getfullargspec(fn).args
varargs = inspect.getfullargspec(fn).varargs
varkw = inspect.getfullargspec(fn).varkw
defaults = inspect.getfullargspec(fn).defaults
fnargs = inspect.getfullargspec(fn).args
fnargs.extend(inspect.getfullargspec(fn).kwonlyargs)
varkw = inspect.getfullargspec(fn).varkw
newargs = {}
for a in kwargs:
@ -1626,6 +1615,12 @@ def enqueue(*args, **kwargs):
import frappe.utils.background_jobs
return frappe.utils.background_jobs.enqueue(*args, **kwargs)
def task(**task_kwargs):
def decorator_task(f):
f.enqueue = lambda **fun_kwargs: enqueue(f, **task_kwargs, **fun_kwargs)
return f
return decorator_task
def enqueue_doc(*args, **kwargs):
'''
Enqueue method to be executed using a background worker

View file

@ -185,7 +185,7 @@ def make_form_dict(request):
args = request.form or request.args
if not isinstance(args, dict):
frappe.throw("Invalid request arguments")
frappe.throw(_("Invalid request arguments"))
try:
frappe.local.form_dict = frappe._dict({ k:v[0] if isinstance(v, (list, tuple)) else v \
@ -294,8 +294,9 @@ def serve(port=8000, profile=False, no_reload=False, no_threading=False, site=No
_sites_path = sites_path
from werkzeug.serving import run_simple
patch_werkzeug_reloader()
if profile:
if profile or os.environ.get('USE_PROFILER'):
application = ProfilerMiddleware(application, sort_by=('cumtime', 'calls'))
if not os.environ.get('NO_STATICS'):
@ -324,3 +325,23 @@ def serve(port=8000, profile=False, no_reload=False, no_threading=False, site=No
use_debugger=not in_test_env,
use_evalex=not in_test_env,
threaded=not no_threading)
def patch_werkzeug_reloader():
"""
This function monkey patches Werkzeug reloader to ignore reloading files in
the __pycache__ directory.
To be deprecated when upgrading to Werkzeug 2.
"""
from werkzeug._reloader import WatchdogReloaderLoop
trigger_reload = WatchdogReloaderLoop.trigger_reload
def custom_trigger_reload(self, filename):
if os.path.basename(os.path.dirname(filename)) == "__pycache__":
return
return trigger_reload(self, filename)
WatchdogReloaderLoop.trigger_reload = custom_trigger_reload

View file

@ -173,7 +173,7 @@ class TestAutoRepeat(unittest.TestCase):
fields=['docstatus'],
limit=1
)
self.assertEquals(docnames[0].docstatus, 1)
self.assertEqual(docnames[0].docstatus, 1)
def make_auto_repeat(**args):

View file

@ -42,8 +42,6 @@ def get_bootinfo():
bootinfo.user_info = get_user_info()
bootinfo.sid = frappe.session['sid']
bootinfo.user_groups = frappe.get_all('User Group', pluck="name")
bootinfo.modules = {}
bootinfo.module_list = []
load_desktop_data(bootinfo)

View file

@ -327,7 +327,7 @@ def make_asset_dirs(make_copy=False, restore=False):
except OSError:
print("Cannot link {} to {}".format(source, target))
else:
# warnings.warn('Source {source} does not exist.'.format(source = source))
warnings.warn('Source {source} does not exist.'.format(source = source))
pass

View file

@ -0,0 +1,32 @@
# Version 13.2.0 Release Notes
### Features & Enhancements
- Add option to mention a group of users ([#12844](https://github.com/frappe/frappe/pull/12844))
- Copy DocType / documents across sites ([#12872](https://github.com/frappe/frappe/pull/12872))
- Scheduler log in notifications ([#1135](https://github.com/frappe/frappe/pull/1135))
- Add Enable/Disable Webhook via Check Field ([#12842](https://github.com/frappe/frappe/pull/12842))
- Allow query/custom reports to save custom data in the json field ([#12534](https://github.com/frappe/frappe/pull/12534))
### Fixes
- Load server translations in boot (backport #12848) ([#12852](https://github.com/frappe/frappe/pull/12852))
- Allow to override dashboard chart properties type/color ([#12846](https://github.com/frappe/frappe/pull/12846))
- Multi-column paste in grid ([#12861](https://github.com/frappe/frappe/pull/12861))
- Add log_error and FrappeClient to restricted python ([#12857](https://github.com/frappe/frappe/pull/12857))
- Redirect Web Form user directly to success URL, if no amount is due ([#12661](https://github.com/frappe/frappe/pull/12661))
- Attachment pill lock icon redirects to File ([#12864](https://github.com/frappe/frappe/pull/12864))
- Redirect Web Form user directly to success URL, if no amount is due (backport #12661) ([#12856](https://github.com/frappe/frappe/pull/12856))
- Remove events to redraw charts ([#12973](https://github.com/frappe/frappe/pull/12973))
- Don't allow user to remove/change data source file in data import ([#12827](https://github.com/frappe/frappe/pull/12827))
- Load server translations in boot ([#12848](https://github.com/frappe/frappe/pull/12848))
- Newly created Workspace not being accessible unless a shortcut u… ([#12866](https://github.com/frappe/frappe/pull/12866))
- Currency labels in grids ([#12974](https://github.com/frappe/frappe/pull/12974))
- Handle error while session start ([#12933](https://github.com/frappe/frappe/pull/12933))
- Add field type check in custom field validation ([#12858](https://github.com/frappe/frappe/pull/12858))
- Make language select optional and fix breakpoint issues ([#12860](https://github.com/frappe/frappe/pull/12860))
- Form Dashboard reference link ([#12945](https://github.com/frappe/frappe/pull/12945))
- Invalid HTML generated by the base template ([#12953](https://github.com/frappe/frappe/pull/12953))
- Default values were not triggering change event ([#12975](https://github.com/frappe/frappe/pull/12975))
- Make strings translatable ([#12877](https://github.com/frappe/frappe/pull/12877))
- Added build-message-files command ([#12950](https://github.com/frappe/frappe/pull/12950))

View file

@ -1,6 +1,7 @@
# imports - standard imports
import os
import sys
import shutil
# imports - third party imports
import click
@ -202,10 +203,13 @@ def install_app(context, apps):
@click.command("list-apps")
@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text")
@pass_context
def list_apps(context):
def list_apps(context, format):
"List apps in site"
summary_dict = {}
def fix_whitespaces(text):
if site == context.sites[-1]:
text = text.rstrip()
@ -234,18 +238,23 @@ def list_apps(context):
]
applications_summary = "\n".join(installed_applications)
summary = f"{site_title}\n{applications_summary}\n"
summary_dict[site] = [app.app_name for app in apps]
else:
applications_summary = "\n".join(frappe.get_installed_apps())
installed_applications = frappe.get_installed_apps()
applications_summary = "\n".join(installed_applications)
summary = f"{site_title}\n{applications_summary}\n"
summary_dict[site] = installed_applications
summary = fix_whitespaces(summary)
if applications_summary and summary:
if format == "text" and applications_summary and summary:
print(summary)
frappe.destroy()
if format == "json":
click.echo(frappe.as_json(summary_dict))
@click.command('add-system-manager')
@click.argument('email')

View file

@ -96,22 +96,54 @@ def destroy_all_sessions(context, reason=None):
raise SiteNotSpecifiedError
@click.command('show-config')
@click.option("--format", "-f", type=click.Choice(["text", "json"]), default="text")
@pass_context
def show_config(context):
"print configuration file"
print("\t\033[92m{:<50}\033[0m \033[92m{:<15}\033[0m".format('Config','Value'))
sites_path = os.path.join(frappe.utils.get_bench_path(), 'sites')
site_path = context.sites[0]
configuration = frappe.get_site_config(sites_path=sites_path, site_path=site_path)
print_config(configuration)
def show_config(context, format):
"Print configuration file to STDOUT in speified format"
if not context.sites:
raise SiteNotSpecifiedError
def print_config(config):
for conf, value in config.items():
if isinstance(value, dict):
print_config(value)
else:
print("\t{:<50} {:<15}".format(conf, value))
sites_config = {}
sites_path = os.getcwd()
from frappe.utils.commands import render_table
def transform_config(config, prefix=None):
prefix = f"{prefix}." if prefix else ""
site_config = []
for conf, value in config.items():
if isinstance(value, dict):
site_config += transform_config(value, prefix=f"{prefix}{conf}")
else:
log_value = json.dumps(value) if isinstance(value, list) else value
site_config += [[f"{prefix}{conf}", log_value]]
return site_config
for site in context.sites:
frappe.init(site)
if len(context.sites) != 1 and format == "text":
if context.sites.index(site) != 0:
click.echo()
click.secho(f"Site {site}", fg="yellow")
configuration = frappe.get_site_config(sites_path=sites_path, site_path=site)
if format == "text":
data = transform_config(configuration)
data.insert(0, ['Config','Value'])
render_table(data)
if format == "json":
sites_config[site] = configuration
frappe.destroy()
if format == "json":
click.echo(frappe.as_json(sites_config))
@click.command('reset-perms')
@ -470,6 +502,7 @@ def console(context):
locals()[app] = __import__(app)
except ModuleNotFoundError:
failed_to_import.append(app)
all_apps.remove(app)
print("Apps in this namespace:\n{}".format(", ".join(all_apps)))
if failed_to_import:
@ -675,20 +708,27 @@ def make_app(destination, app_name):
@click.command('set-config')
@click.argument('key')
@click.argument('value')
@click.option('-g', '--global', 'global_', is_flag = True, default = False, help = 'Set Global Site Config')
@click.option('--as-dict', is_flag=True, default=False)
@click.option('-g', '--global', 'global_', is_flag=True, default=False, help='Set value in bench config')
@click.option('-p', '--parse', is_flag=True, default=False, help='Evaluate as Python Object')
@click.option('--as-dict', is_flag=True, default=False, help='Legacy: Evaluate as Python Object')
@pass_context
def set_config(context, key, value, global_ = False, as_dict=False):
def set_config(context, key, value, global_=False, parse=False, as_dict=False):
"Insert/Update a value in site_config.json"
from frappe.installer import update_site_config
import ast
if as_dict:
from frappe.utils.commands import warn
warn("--as-dict will be deprecated in v14. Use --parse instead", category=PendingDeprecationWarning)
parse = as_dict
if parse:
import ast
value = ast.literal_eval(value)
if global_:
sites_path = os.getcwd() # big assumption.
sites_path = os.getcwd()
common_site_config_path = os.path.join(sites_path, 'common_site_config.json')
update_site_config(key, value, validate = False, site_config_path = common_site_config_path)
update_site_config(key, value, validate=False, site_config_path=common_site_config_path)
else:
for site in context.sites:
frappe.init(site=site)
@ -745,50 +785,6 @@ def rebuild_global_search(context, static_pages=False):
if not context.sites:
raise SiteNotSpecifiedError
@click.command('auto-deploy')
@click.argument('app')
@click.option('--migrate', is_flag=True, default=False, help='Migrate after pulling')
@click.option('--restart', is_flag=True, default=False, help='Restart after migration')
@click.option('--remote', default='upstream', help='Remote, default is "upstream"')
@pass_context
def auto_deploy(context, app, migrate=False, restart=False, remote='upstream'):
'''Pull and migrate sites that have new version'''
from frappe.utils.gitutils import get_app_branch
from frappe.utils import get_sites
branch = get_app_branch(app)
app_path = frappe.get_app_path(app)
# fetch
subprocess.check_output(['git', 'fetch', remote, branch], cwd = app_path)
# get diff
if subprocess.check_output(['git', 'diff', '{0}..{1}/{0}'.format(branch, remote)], cwd = app_path):
print('Updates found for {0}'.format(app))
if app=='frappe':
# run bench update
import shlex
subprocess.check_output(shlex.split('bench update --no-backup'), cwd = '..')
else:
updated = False
subprocess.check_output(['git', 'pull', '--rebase', remote, branch],
cwd = app_path)
# find all sites with that app
for site in get_sites():
frappe.init(site)
if app in frappe.get_installed_apps():
print('Updating {0}'.format(site))
updated = True
subprocess.check_output(['bench', '--site', site, 'clear-cache'], cwd = '..')
if migrate:
subprocess.check_output(['bench', '--site', site, 'migrate'], cwd = '..')
frappe.destroy()
if updated or restart:
subprocess.check_output(['bench', 'restart'], cwd = '..')
else:
print('No Updates')
commands = [
build,

View file

@ -65,12 +65,12 @@ class TestActivityLog(unittest.TestCase):
frappe.local.login_manager = LoginManager()
auth_log = self.get_auth_log()
self.assertEquals(auth_log.status, 'Success')
self.assertEqual(auth_log.status, 'Success')
# test user logout log
frappe.local.login_manager.logout()
auth_log = self.get_auth_log(operation='Logout')
self.assertEquals(auth_log.status, 'Success')
self.assertEqual(auth_log.status, 'Success')
# test invalid login
frappe.form_dict.update({ 'pwd': 'password' })

View file

@ -272,22 +272,13 @@ def prepare_to_notify(doc, print_html=None, print_format=None, attachments=None)
doc.attachments.append(a)
def set_incoming_outgoing_accounts(doc):
doc.incoming_email_account = doc.outgoing_email_account = None
from frappe.email.doctype.email_account.email_account import EmailAccount
incoming_email_account = EmailAccount.find_incoming(
match_by_email=doc.sender, match_by_doctype=doc.reference_doctype)
doc.incoming_email_account = incoming_email_account.email_id if incoming_email_account else None
if not doc.incoming_email_account and doc.sender:
doc.incoming_email_account = frappe.db.get_value("Email Account",
{"email_id": doc.sender, "enable_incoming": 1}, "email_id")
if not doc.incoming_email_account and doc.reference_doctype:
doc.incoming_email_account = frappe.db.get_value("Email Account",
{"append_to": doc.reference_doctype, }, "email_id")
if not doc.incoming_email_account:
doc.incoming_email_account = frappe.db.get_value("Email Account",
{"default_incoming": 1, "enable_incoming": 1}, "email_id")
doc.outgoing_email_account = frappe.email.smtp.get_outgoing_email_account(raise_exception_not_set=False,
append_to=doc.doctype, sender=doc.sender)
doc.outgoing_email_account = EmailAccount.find_outgoing(
match_by_email=doc.sender, match_by_doctype=doc.reference_doctype)
if doc.sent_or_received == "Sent":
doc.db_set("email_account", doc.outgoing_email_account.name)

View file

@ -233,7 +233,7 @@ class Importer:
return updated_doc
else:
# throw if no changes
frappe.throw("No changes to update")
frappe.throw(_("No changes to update"))
def get_eta(self, current, total, processing_time):
self.last_eta = getattr(self, "last_eta", 0)
@ -319,7 +319,7 @@ class ImportFile:
self.warnings = []
self.file_doc = self.file_path = self.google_sheets_url = None
if isinstance(file, frappe.string_types):
if isinstance(file, str):
if frappe.db.exists("File", {"file_url": file}):
self.file_doc = frappe.get_doc("File", {"file_url": file})
elif "docs.google.com/spreadsheets" in file:
@ -626,7 +626,7 @@ class Row:
return
elif df.fieldtype in ["Date", "Datetime"]:
value = self.get_date(value, col)
if isinstance(value, frappe.string_types):
if isinstance(value, str):
# value was not parsed as datetime object
self.warnings.append(
{

View file

@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright (c) {year}, {app_publisher} and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
{base_class_import}

View file

@ -1,7 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright (c) {year}, {app_publisher} and Contributors
# See license.txt
from __future__ import unicode_literals
# import frappe
import unittest

View file

@ -662,4 +662,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View file

@ -1,7 +1,6 @@
# Copyright (c) 2013, {app_publisher} and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
# import frappe
def execute(filters=None):

View file

@ -106,7 +106,7 @@ class TestReport(unittest.TestCase):
else:
report = frappe.get_doc('Report', 'Test Report')
self.assertNotEquals(report.is_permitted(), True)
self.assertNotEqual(report.is_permitted(), True)
frappe.set_user('Administrator')
# test for the `_format` method if report data doesn't have sort_by parameter

View file

@ -56,6 +56,7 @@ class User(Document):
def after_insert(self):
create_notification_settings(self.name)
frappe.cache().delete_key('users_for_mentions')
def validate(self):
self.check_demo()
@ -129,6 +130,9 @@ class User(Document):
if self.time_zone:
frappe.defaults.set_default("time_zone", self.time_zone, self.name)
if self.has_value_changed('allow_in_mentions') or self.has_value_changed('user_type'):
frappe.cache().delete_key('users_for_mentions')
def has_website_permission(self, ptype, user, verbose=False):
"""Returns true if current user is the session user"""
return self.name == frappe.session.user
@ -389,6 +393,9 @@ class User(Document):
# delete notification settings
frappe.delete_doc("Notification Settings", self.name, ignore_permissions=True)
if self.get('allow_in_mentions'):
frappe.cache().delete_key('users_for_mentions')
def before_rename(self, old_name, new_name, merge=False):
self.check_demo()

View file

@ -9,7 +9,7 @@ import frappe
class UserGroup(Document):
def after_insert(self):
frappe.publish_realtime('user_group_added', self.name)
frappe.cache().delete_key('user_groups')
def on_trash(self):
frappe.publish_realtime('user_group_deleted', self.name)
frappe.cache().delete_key('user_groups')

View file

@ -46,7 +46,7 @@ class TestUserPermission(unittest.TestCase):
frappe.set_user('test_user_perm1@example.com')
doc = frappe.new_doc("Blog Post")
self.assertEquals(doc.blog_category, 'general')
self.assertEqual(doc.blog_category, 'general')
frappe.set_user('Administrator')
def test_apply_to_all(self):
@ -54,7 +54,7 @@ class TestUserPermission(unittest.TestCase):
user = create_user('test_bulk_creation_update@example.com')
param = get_params(user, 'User', user.name)
is_created = add_user_permissions(param)
self.assertEquals(is_created, 1)
self.assertEqual(is_created, 1)
def test_for_apply_to_all_on_update_from_apply_all(self):
user = create_user('test_bulk_creation_update@example.com')
@ -63,11 +63,11 @@ class TestUserPermission(unittest.TestCase):
# Initially create User Permission document with apply_to_all checked
is_created = add_user_permissions(param)
self.assertEquals(is_created, 1)
self.assertEqual(is_created, 1)
is_created = add_user_permissions(param)
# User Permission should not be changed
self.assertEquals(is_created, 0)
self.assertEqual(is_created, 0)
def test_for_applicable_on_update_from_apply_to_all(self):
''' Update User Permission from all to some applicable Doctypes'''
@ -77,7 +77,7 @@ class TestUserPermission(unittest.TestCase):
# Initially create User Permission document with apply_to_all checked
is_created = add_user_permissions(get_params(user, 'User', user.name))
self.assertEquals(is_created, 1)
self.assertEqual(is_created, 1)
is_created = add_user_permissions(param)
frappe.db.commit()
@ -92,7 +92,7 @@ class TestUserPermission(unittest.TestCase):
# Check that User Permissions for applicable is created
self.assertIsNotNone(is_created_applicable_first)
self.assertIsNotNone(is_created_applicable_second)
self.assertEquals(is_created, 1)
self.assertEqual(is_created, 1)
def test_for_apply_to_all_on_update_from_applicable(self):
''' Update User Permission from some to all applicable Doctypes'''
@ -102,7 +102,7 @@ class TestUserPermission(unittest.TestCase):
# create User permissions that with applicable
is_created = add_user_permissions(get_params(user, 'User', user.name, applicable = ["Chat Room", "Chat Message"]))
self.assertEquals(is_created, 1)
self.assertEqual(is_created, 1)
is_created = add_user_permissions(param)
is_created_apply_to_all = frappe.db.exists("User Permission", get_exists_param(user))
@ -115,7 +115,7 @@ class TestUserPermission(unittest.TestCase):
# Check that all User Permission with applicable is removed
self.assertIsNone(removed_applicable_first)
self.assertIsNone(removed_applicable_second)
self.assertEquals(is_created, 1)
self.assertEqual(is_created, 1)
def test_user_perm_for_nested_doctype(self):
"""Test if descendants' visibility is controlled for a nested DocType."""
@ -183,7 +183,7 @@ class TestUserPermission(unittest.TestCase):
# User perm is created on ToDo but for doctype Assignment Rule only
# it should not have impact on Doc A
self.assertEquals(new_doc.doc, "ToDo")
self.assertEqual(new_doc.doc, "ToDo")
frappe.set_user('Administrator')
remove_applicable(["Assignment Rule"], "new_doc_test@example.com", "DocType", "ToDo")
@ -228,7 +228,7 @@ class TestUserPermission(unittest.TestCase):
# User perm is created on ToDo but for doctype Assignment Rule only
# it should not have impact on Doc A
self.assertEquals(new_doc.doc, "ToDo")
self.assertEqual(new_doc.doc, "ToDo")
frappe.set_user('Administrator')
clear_session_defaults()

View file

@ -191,7 +191,7 @@ def clear_user_permissions(user, for_doctype):
def add_user_permissions(data):
''' Add and update the user permissions '''
frappe.only_for('System Manager')
if isinstance(data, frappe.string_types):
if isinstance(data, str):
data = json.loads(data)
data = frappe._dict(data)

View file

@ -1,7 +1,7 @@
frappe.pages['recorder'].on_page_load = function(wrapper) {
frappe.ui.make_app_page({
parent: wrapper,
title: 'Recorder',
title: __('Recorder'),
single_column: true,
card_layout: true
});

View file

@ -68,14 +68,15 @@ class CustomField(Document):
check_if_fieldname_conflicts_with_methods(self.dt, self.fieldname)
def on_update(self):
frappe.clear_cache(doctype=self.dt)
if not frappe.flags.in_setup_wizard:
frappe.clear_cache(doctype=self.dt)
if not self.flags.ignore_validate:
# validate field
from frappe.core.doctype.doctype.doctype import validate_fields_for_doctype
validate_fields_for_doctype(self.dt)
# update the schema
if not frappe.db.get_value('DocType', self.dt, 'issingle'):
if not frappe.db.get_value('DocType', self.dt, 'issingle') and not frappe.flags.in_setup_wizard:
frappe.db.updatedb(self.dt)
def on_trash(self):
@ -144,6 +145,10 @@ def create_custom_fields(custom_fields, ignore_validate = False, update=True):
'''Add / update multiple custom fields
:param custom_fields: example `{'Sales Invoice': [dict(fieldname='test')]}`'''
if not ignore_validate and frappe.flags.in_setup_wizard:
ignore_validate = True
for doctype, fields in custom_fields.items():
if isinstance(fields, dict):
# only one field
@ -163,6 +168,10 @@ def create_custom_fields(custom_fields, ignore_validate = False, update=True):
custom_field.update(df)
custom_field.save()
frappe.clear_cache(doctype=doctype)
frappe.db.updatedb(doctype)
@frappe.whitelist()
def add_custom_field(doctype, df):

View file

@ -278,6 +278,7 @@
},
{
"collapsible": 1,
"depends_on": "doc_type",
"fieldname": "naming_section",
"fieldtype": "Section Break",
"label": "Naming"
@ -287,6 +288,16 @@
"fieldname": "autoname",
"fieldtype": "Data",
"label": "Auto Name"
},
{
"fieldname": "default_email_template",
"fieldtype": "Link",
"label": "Default Email Template",
"options": "Email Template"
},
{
"fieldname": "column_break_26",
"fieldtype": "Column Break"
}
],
"hide_toolbar": 1,
@ -295,7 +306,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2021-03-22 12:27:15.462727",
"modified": "2021-04-29 21:21:06.476372",
"modified_by": "Administrator",
"module": "Custom",
"name": "Customize Form",
@ -316,4 +327,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View file

@ -47,64 +47,64 @@ class TestCustomizeForm(unittest.TestCase):
self.assertEqual(len(d.get("fields")), 0)
d = self.get_customize_form("Event")
self.assertEquals(d.doc_type, "Event")
self.assertEquals(len(d.get("fields")), 36)
self.assertEqual(d.doc_type, "Event")
self.assertEqual(len(d.get("fields")), 36)
d = self.get_customize_form("Event")
self.assertEquals(d.doc_type, "Event")
self.assertEqual(d.doc_type, "Event")
self.assertEqual(len(d.get("fields")),
len(frappe.get_doc("DocType", d.doc_type).fields) + 1)
self.assertEquals(d.get("fields")[-1].fieldname, "test_custom_field")
self.assertEquals(d.get("fields", {"fieldname": "event_type"})[0].in_list_view, 1)
self.assertEqual(d.get("fields")[-1].fieldname, "test_custom_field")
self.assertEqual(d.get("fields", {"fieldname": "event_type"})[0].in_list_view, 1)
return d
def test_save_customization_property(self):
d = self.get_customize_form("Event")
self.assertEquals(frappe.db.get_value("Property Setter",
self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "allow_copy"}, "value"), None)
d.allow_copy = 1
d.run_method("save_customization")
self.assertEquals(frappe.db.get_value("Property Setter",
self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "allow_copy"}, "value"), '1')
d.allow_copy = 0
d.run_method("save_customization")
self.assertEquals(frappe.db.get_value("Property Setter",
self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "allow_copy"}, "value"), None)
def test_save_customization_field_property(self):
d = self.get_customize_form("Event")
self.assertEquals(frappe.db.get_value("Property Setter",
self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "reqd", "field_name": "repeat_this_event"}, "value"), None)
repeat_this_event_field = d.get("fields", {"fieldname": "repeat_this_event"})[0]
repeat_this_event_field.reqd = 1
d.run_method("save_customization")
self.assertEquals(frappe.db.get_value("Property Setter",
self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "reqd", "field_name": "repeat_this_event"}, "value"), '1')
repeat_this_event_field = d.get("fields", {"fieldname": "repeat_this_event"})[0]
repeat_this_event_field.reqd = 0
d.run_method("save_customization")
self.assertEquals(frappe.db.get_value("Property Setter",
self.assertEqual(frappe.db.get_value("Property Setter",
{"doc_type": "Event", "property": "reqd", "field_name": "repeat_this_event"}, "value"), None)
def test_save_customization_custom_field_property(self):
d = self.get_customize_form("Event")
self.assertEquals(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
custom_field = d.get("fields", {"fieldname": "test_custom_field"})[0]
custom_field.reqd = 1
d.run_method("save_customization")
self.assertEquals(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 1)
self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 1)
custom_field = d.get("fields", {"is_custom_field": True})[0]
custom_field.reqd = 0
d.run_method("save_customization")
self.assertEquals(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
self.assertEqual(frappe.db.get_value("Custom Field", "Event-test_custom_field", "reqd"), 0)
def test_save_customization_new_field(self):
d = self.get_customize_form("Event")
@ -115,14 +115,14 @@ class TestCustomizeForm(unittest.TestCase):
"is_custom_field": 1
})
d.run_method("save_customization")
self.assertEquals(frappe.db.get_value("Custom Field",
self.assertEqual(frappe.db.get_value("Custom Field",
"Event-test_add_custom_field_via_customize_form", "fieldtype"), "Data")
self.assertEquals(frappe.db.get_value("Custom Field",
self.assertEqual(frappe.db.get_value("Custom Field",
"Event-test_add_custom_field_via_customize_form", 'insert_after'), last_fieldname)
frappe.delete_doc("Custom Field", "Event-test_add_custom_field_via_customize_form")
self.assertEquals(frappe.db.get_value("Custom Field",
self.assertEqual(frappe.db.get_value("Custom Field",
"Event-test_add_custom_field_via_customize_form"), None)
@ -142,7 +142,7 @@ class TestCustomizeForm(unittest.TestCase):
d.doc_type = "Event"
d.run_method('reset_to_defaults')
self.assertEquals(d.get("fields", {"fieldname": "repeat_this_event"})[0].in_list_view, 0)
self.assertEqual(d.get("fields", {"fieldname": "repeat_this_event"})[0].in_list_view, 0)
frappe.local.test_objects["Property Setter"] = []
make_test_records_for_doctype("Property Setter")
@ -156,7 +156,7 @@ class TestCustomizeForm(unittest.TestCase):
d = self.get_customize_form("Event")
# don't allow for standard fields
self.assertEquals(d.get("fields", {"fieldname": "subject"})[0].allow_on_submit or 0, 0)
self.assertEqual(d.get("fields", {"fieldname": "subject"})[0].allow_on_submit or 0, 0)
# allow for custom field
self.assertEqual(d.get("fields", {"fieldname": "test_custom_field"})[0].allow_on_submit, 1)

View file

@ -858,7 +858,7 @@ class Database(object):
if not datetime:
return '0001-01-01 00:00:00.000000'
if isinstance(datetime, frappe.string_types):
if isinstance(datetime, str):
if ':' not in datetime:
datetime = datetime + ' 00:00:00.000000'
else:

View file

@ -1,5 +1,3 @@
import warnings
import pymysql
from pymysql.constants import ER, FIELD_TYPE
from pymysql.converters import conversions, escape_string
@ -55,7 +53,6 @@ class MariaDBDatabase(Database):
}
def get_connection(self):
warnings.filterwarnings('ignore', category=pymysql.Warning)
usessl = 0
if frappe.conf.db_ssl_ca and frappe.conf.db_ssl_cert and frappe.conf.db_ssl_key:
usessl = 1

View file

@ -1,5 +1,3 @@
from __future__ import unicode_literals
import re
import frappe
import psycopg2
@ -13,9 +11,9 @@ from frappe.database.postgres.schema import PostgresTable
# cast decimals as floats
DEC2FLOAT = psycopg2.extensions.new_type(
psycopg2.extensions.DECIMAL.values,
'DEC2FLOAT',
lambda value, curs: float(value) if value is not None else None)
psycopg2.extensions.DECIMAL.values,
'DEC2FLOAT',
lambda value, curs: float(value) if value is not None else None)
psycopg2.extensions.register_type(DEC2FLOAT)
@ -65,7 +63,6 @@ class PostgresDatabase(Database):
}
def get_connection(self):
# warnings.filterwarnings('ignore', category=psycopg2.Warning)
conn = psycopg2.connect("host='{}' dbname='{}' user='{}' password='{}' port={}".format(
self.host, self.user, self.user, self.password, self.port
))
@ -114,7 +111,7 @@ class PostgresDatabase(Database):
if not date:
return '0001-01-01'
if not isinstance(date, frappe.string_types):
if not isinstance(date, str):
date = date.strftime('%Y-%m-%d')
return date

View file

@ -46,7 +46,7 @@ def enqueue_create_notification(users, doc):
doc = frappe._dict(doc)
if isinstance(users, frappe.string_types):
if isinstance(users, str):
users = [user.strip() for user in users.split(',') if user.strip()]
users = list(set(users))

View file

@ -5,6 +5,7 @@
.download-backup-card {
display: block;
text-decoration: none;
margin-bottom: var(--margin-lg);
}
.download-backup-card:hover {

View file

@ -1,7 +1,7 @@
frappe.pages['backups'].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'Download Backups',
title: __('Download Backups'),
single_column: true
});

View file

@ -124,6 +124,7 @@ def handle_setup_exception(args):
frappe.db.rollback()
if args:
traceback = frappe.get_traceback()
print(traceback)
for hook in frappe.get_hooks("setup_wizard_exception"):
frappe.get_attr(hook)(traceback, args)

View file

@ -1,7 +1,7 @@
frappe.pages['translation-tool'].on_page_load = function(wrapper) {
var page = frappe.ui.make_app_page({
parent: wrapper,
title: 'Translation Tool',
title: __('Translation Tool'),
single_column: true,
card_layout: true,
});

View file

@ -8,7 +8,7 @@
</div>
<div class="chart-wrapper performance-heatmap">
<div class="null-state">
<span>No Data to Show</span>
<span>{%=__("No Data to Show") %}</span>
</div>
</div>
</div>
@ -19,7 +19,7 @@
</div>
<div class="chart-wrapper performance-percentage-chart">
<div class="null-state">
<span>No Data to Show</span>
<span>{%=__("No Data to Show") %}</span>
</div>
</div>
</div>
@ -30,7 +30,7 @@
</div>
<div class="chart-wrapper performance-line-chart">
<div class="null-state">
<span>No Data to Show</span>
<span>{%=__("No Data to Show") %}</span>
</div>
</div>
</div>
@ -41,4 +41,4 @@
<div class="recent-activity-footer"></div>
</div>
</div>
</div>
</div>

View file

@ -377,10 +377,17 @@ def handle_duration_fieldtype_values(result, columns):
if fieldtype == "Duration":
for entry in range(0, len(result)):
val_in_seconds = result[entry][i]
if val_in_seconds:
duration_val = format_duration(val_in_seconds)
result[entry][i] = duration_val
row = result[entry]
if isinstance(row, dict):
val_in_seconds = row[col.fieldname]
if val_in_seconds:
duration_val = format_duration(val_in_seconds)
row[col.fieldname] = duration_val
else:
val_in_seconds = row[i]
if val_in_seconds:
duration_val = format_duration(val_in_seconds)
row[i] = duration_val
return result

View file

@ -221,3 +221,37 @@ def validate_and_sanitize_search_inputs(fn, instance, args, kwargs):
return []
return fn(**kwargs)
@frappe.whitelist()
def get_names_for_mentions(search_term):
users_for_mentions = frappe.cache().get_value('users_for_mentions', get_users_for_mentions)
user_groups = frappe.cache().get_value('user_groups', get_user_groups)
filtered_mentions = []
for mention_data in users_for_mentions + user_groups:
if search_term.lower() not in mention_data.value.lower():
continue
mention_data['link'] = frappe.utils.get_url_to_form(
'User Group' if mention_data.get('is_group') else 'User Profile',
mention_data['id']
)
filtered_mentions.append(mention_data)
return sorted(filtered_mentions, key=lambda d: d['value'])
def get_users_for_mentions():
return frappe.get_all('User',
fields=['name as id', 'full_name as value'],
filters={
'name': ['not in', ('Administrator', 'Guest')],
'allowed_in_mentions': True,
'user_type': 'System User',
})
def get_user_groups():
return frappe.get_all('User Group', fields=['name as id', 'name as value'], update={
'is_group': True
})

View file

@ -17,14 +17,14 @@ class TestDocumentFollow(unittest.TestCase):
document_follow.unfollow_document("Event", event_doc.name, user.name)
doc = document_follow.follow_document("Event", event_doc.name, user.name)
self.assertEquals(doc.user, user.name)
self.assertEqual(doc.user, user.name)
document_follow.send_hourly_updates()
email_queue_entry_name = frappe.get_all("Email Queue", limit=1)[0].name
email_queue_entry_doc = frappe.get_doc("Email Queue", email_queue_entry_name)
self.assertEquals((email_queue_entry_doc.recipients[0].recipient), user.name)
self.assertEqual((email_queue_entry_doc.recipients[0].recipient), user.name)
self.assertIn(event_doc.doctype, email_queue_entry_doc.message)
self.assertIn(event_doc.name, email_queue_entry_doc.message)

View file

@ -8,9 +8,14 @@ import re
import json
import socket
import time
from frappe import _
import functools
import email.utils
from frappe import _, are_emails_muted
from frappe.model.document import Document
from frappe.utils import validate_email_address, cint, cstr, get_datetime, DATE_FORMAT, strip, comma_or, sanitize_html, add_days
from frappe.utils import (validate_email_address, cint, cstr, get_datetime,
DATE_FORMAT, strip, comma_or, sanitize_html, add_days, parse_addr)
from frappe.utils.user import is_system_user
from frappe.utils.jinja import render_template
from frappe.email.smtp import SMTPServer
@ -21,17 +26,37 @@ from datetime import datetime, timedelta
from frappe.desk.form import assign_to
from frappe.utils.user import get_system_managers
from frappe.utils.background_jobs import enqueue, get_jobs
from frappe.core.doctype.communication.email import set_incoming_outgoing_accounts
from frappe.utils.html_utils import clean_email_html
from frappe.utils.error import raise_error_on_no_output
from frappe.email.utils import get_port
OUTGOING_EMAIL_ACCOUNT_MISSING = _("Please setup default Email Account from Setup > Email > Email Account")
class SentEmailInInbox(Exception):
pass
class InvalidEmailCredentials(frappe.ValidationError):
pass
def cache_email_account(cache_name):
def decorator_cache_email_account(func):
@functools.wraps(func)
def wrapper_cache_email_account(*args, **kwargs):
if not hasattr(frappe.local, cache_name):
setattr(frappe.local, cache_name, {})
cached_accounts = getattr(frappe.local, cache_name)
match_by = list(kwargs.values()) + ['default']
matched_accounts = list(filter(None, [cached_accounts.get(key) for key in match_by]))
if matched_accounts:
return matched_accounts[0]
matched_accounts = func(*args, **kwargs)
cached_accounts.update(matched_accounts or {})
return matched_accounts and list(matched_accounts.values())[0]
return wrapper_cache_email_account
return decorator_cache_email_account
class EmailAccount(Document):
DOCTYPE = 'Email Account'
def autoname(self):
"""Set name as `email_account_name` or make title from Email Address."""
if not self.email_account_name:
@ -72,9 +97,8 @@ class EmailAccount(Document):
self.get_incoming_server()
self.no_failed = 0
if self.enable_outgoing:
self.check_smtp()
self.validate_smtp_conn()
else:
if self.enable_incoming or (self.enable_outgoing and not self.no_smtp_authentication):
frappe.throw(_("Password is required or select Awaiting Password"))
@ -90,6 +114,13 @@ 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 validate_smtp_conn(self):
if not self.smtp_server:
frappe.throw(_("SMTP Server is required"))
server = self.get_smtp_server()
return server.session
def before_save(self):
messages = []
as_list = 1
@ -151,24 +182,6 @@ class EmailAccount(Document):
except Exception:
pass
def check_smtp(self):
"""Checks SMTP settings."""
if self.enable_outgoing:
if not self.smtp_server:
frappe.throw(_("{0} is required").format("SMTP Server"))
server = SMTPServer(
login = getattr(self, "login_id", None) or self.email_id,
server=self.smtp_server,
port=cint(self.smtp_port),
use_tls=cint(self.use_tls),
use_ssl=cint(self.use_ssl_for_outgoing)
)
if self.password and not self.no_smtp_authentication:
server.password = self.get_password()
server.sess
def get_incoming_server(self, in_receive=False, email_sync_rule="UNSEEN"):
"""Returns logged in POP3/IMAP connection object."""
if frappe.cache().get_value("workers:no-internet") == True:
@ -231,7 +244,7 @@ class EmailAccount(Document):
return None
elif not in_receive and any(map(lambda t: t in message, auth_error_codes)):
self.throw_invalid_credentials_exception()
SMTPServer.throw_invalid_credentials_exception()
else:
frappe.throw(cstr(e))
@ -249,13 +262,142 @@ class EmailAccount(Document):
else:
raise
@property
def _password(self):
raise_exception = not (self.no_smtp_authentication or frappe.flags.in_test)
return self.get_password(raise_exception=raise_exception)
@property
def default_sender(self):
return email.utils.formataddr((self.name, self.get("email_id")))
def is_exists_in_db(self):
"""Some of the Email Accounts we create from configs and those doesn't exists in DB.
This is is to check the specific email account exists in DB or not.
"""
return self.find_one_by_filters(name=self.name)
@classmethod
def throw_invalid_credentials_exception(cls):
frappe.throw(
_("Incorrect email or password. Please check your login credentials."),
exc=InvalidEmailCredentials,
title=_("Invalid Credentials")
)
def from_record(cls, record):
email_account = frappe.new_doc(cls.DOCTYPE)
email_account.update(record)
return email_account
@classmethod
def find(cls, name):
return frappe.get_doc(cls.DOCTYPE, name)
@classmethod
def find_one_by_filters(cls, **kwargs):
name = frappe.db.get_value(cls.DOCTYPE, kwargs)
return cls.find(name) if name else None
@classmethod
def find_from_config(cls):
config = cls.get_account_details_from_site_config()
return cls.from_record(config) if config else None
@classmethod
def create_dummy(cls):
return cls.from_record({"sender": "notifications@example.com"})
@classmethod
@raise_error_on_no_output(
keep_quiet = lambda: not cint(frappe.get_system_settings('setup_complete')),
error_message = OUTGOING_EMAIL_ACCOUNT_MISSING, error_type = frappe.OutgoingEmailError) # noqa
@cache_email_account('outgoing_email_account')
def find_outgoing(cls, match_by_email=None, match_by_doctype=None, _raise_error=False):
"""Find the outgoing Email account to use.
:param match_by_email: Find account using emailID
:param match_by_doctype: Find account by matching `Append To` doctype
:param _raise_error: This is used by raise_error_on_no_output decorator to raise error.
"""
if match_by_email:
match_by_email = parse_addr(match_by_email)[1]
doc = cls.find_one_by_filters(enable_outgoing=1, email_id=match_by_email)
if doc:
return {match_by_email: doc}
if match_by_doctype:
doc = cls.find_one_by_filters(enable_outgoing=1, enable_incoming=1, append_to=match_by_doctype)
if doc:
return {match_by_doctype: doc}
doc = cls.find_default_outgoing()
if doc:
return {'default': doc}
@classmethod
def find_default_outgoing(cls):
""" Find default outgoing account.
"""
doc = cls.find_one_by_filters(enable_outgoing=1, default_outgoing=1)
doc = doc or cls.find_from_config()
return doc or (are_emails_muted() and cls.create_dummy())
@classmethod
def find_incoming(cls, match_by_email=None, match_by_doctype=None):
"""Find the incoming Email account to use.
:param match_by_email: Find account using emailID
:param match_by_doctype: Find account by matching `Append To` doctype
"""
doc = cls.find_one_by_filters(enable_incoming=1, email_id=match_by_email)
if doc:
return doc
doc = cls.find_one_by_filters(enable_incoming=1, append_to=match_by_doctype)
if doc:
return doc
doc = cls.find_default_incoming()
return doc
@classmethod
def find_default_incoming(cls):
doc = cls.find_one_by_filters(enable_incoming=1, default_incoming=1)
return doc
@classmethod
def get_account_details_from_site_config(cls):
if not frappe.conf.get("mail_server"):
return {}
field_to_conf_name_map = {
'smtp_server': {'conf_names': ('mail_server',)},
'smtp_port': {'conf_names': ('mail_port',)},
'use_tls': {'conf_names': ('use_tls', 'mail_login')},
'login_id': {'conf_names': ('mail_login',)},
'email_id': {'conf_names': ('auto_email_id', 'mail_login'), 'default': 'notifications@example.com'},
'password': {'conf_names': ('mail_password',)},
'always_use_account_email_id_as_sender':
{'conf_names': ('always_use_account_email_id_as_sender',), 'default': 0},
'always_use_account_name_as_sender_name':
{'conf_names': ('always_use_account_name_as_sender_name',), 'default': 0},
'name': {'conf_names': ('email_sender_name',), 'default': 'Frappe'},
'from_site_config': {'default': True}
}
account_details = {}
for doc_field_name, d in field_to_conf_name_map.items():
conf_names, default = d.get('conf_names') or [], d.get('default')
value = [frappe.conf.get(k) for k in conf_names if frappe.conf.get(k)]
account_details[doc_field_name] = (value and value[0]) or default
return account_details
def sendmail_config(self):
return {
'server': self.smtp_server,
'port': cint(self.smtp_port),
'login': getattr(self, "login_id", None) or self.email_id,
'password': self._password,
'use_ssl': cint(self.use_ssl_for_outgoing),
'use_tls': cint(self.use_tls)
}
def get_smtp_server(self):
config = self.sendmail_config()
return SMTPServer(**config)
def handle_incoming_connect_error(self, description):
if test_internet():
@ -642,6 +784,8 @@ class EmailAccount(Document):
def send_auto_reply(self, communication, email):
"""Send auto reply if set."""
from frappe.core.doctype.communication.email import set_incoming_outgoing_accounts
if self.enable_auto_reply:
set_incoming_outgoing_accounts(communication)
@ -653,7 +797,7 @@ class EmailAccount(Document):
frappe.sendmail(recipients = [email.from_email],
sender = self.email_id,
reply_to = communication.incoming_email_account,
subject = _("Re: ") + communication.subject,
subject = " ".join([_("Re:"), communication.subject]),
content = render_template(self.auto_reply_message or "", communication.as_dict()) or \
frappe.get_template("templates/emails/auto_reply.html").render(communication.as_dict()),
reference_doctype = communication.reference_doctype,

View file

@ -10,7 +10,8 @@
"incoming_port": "993",
"attachment_limit": "1",
"smtp_server": "smtp.test.com",
"smtp_port": "587"
"smtp_port": "587",
"password": "password"
},
{
"doctype": "Email Account",
@ -25,6 +26,7 @@
"incoming_port": "143",
"attachment_limit": "1",
"smtp_server": "smtp.test.com",
"smtp_port": "587"
"smtp_port": "587",
"password": "password"
}
]

View file

@ -24,7 +24,8 @@
"unsubscribe_method",
"expose_recipients",
"attachments",
"retry"
"retry",
"email_account"
],
"fields": [
{
@ -139,13 +140,19 @@
"fieldtype": "Int",
"label": "Retry",
"read_only": 1
},
{
"fieldname": "email_account",
"fieldtype": "Link",
"label": "Email Account",
"options": "Email Account"
}
],
"icon": "fa fa-envelope",
"idx": 1,
"in_create": 1,
"links": [],
"modified": "2020-07-17 15:58:15.369419",
"modified": "2021-04-29 06:33:25.191729",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Queue",

View file

@ -2,15 +2,26 @@
# Copyright (c) 2015, Frappe Technologies and contributors
# For license information, please see license.txt
from __future__ import unicode_literals
import traceback
import json
from rq.timeouts import JobTimeoutException
import smtplib
import quopri
from email.parser import Parser
import frappe
from frappe import _
from frappe import _, safe_encode, task
from frappe.model.document import Document
from frappe.email.queue import send_one
from frappe.utils import now_datetime
from frappe.email.queue import get_unsubcribed_url
from frappe.email.email_body import add_attachment
from frappe.utils import cint
from email.policy import SMTPUTF8
MAX_RETRY_COUNT = 3
class EmailQueue(Document):
DOCTYPE = 'Email Queue'
def set_recipients(self, recipients):
self.set("recipients", [])
for r in recipients:
@ -30,6 +41,241 @@ class EmailQueue(Document):
duplicate.set_recipients(recipients)
return duplicate
@classmethod
def find(cls, name):
return frappe.get_doc(cls.DOCTYPE, name)
def update_db(self, commit=False, **kwargs):
frappe.db.set_value(self.DOCTYPE, self.name, kwargs)
if commit:
frappe.db.commit()
def update_status(self, status, commit=False, **kwargs):
self.update_db(status = status, commit = commit, **kwargs)
if self.communication:
communication_doc = frappe.get_doc('Communication', self.communication)
communication_doc.set_delivery_status(commit=commit)
@property
def cc(self):
return (self.show_as_cc and self.show_as_cc.split(",")) or []
@property
def to(self):
return [r.recipient for r in self.recipients if r.recipient not in self.cc]
@property
def attachments_list(self):
return json.loads(self.attachments) if self.attachments else []
def get_email_account(self):
from frappe.email.doctype.email_account.email_account import EmailAccount
if self.email_account:
return frappe.get_doc('Email Account', self.email_account)
return EmailAccount.find_outgoing(
match_by_email = self.sender, match_by_doctype = self.reference_doctype)
def is_to_be_sent(self):
return self.status in ['Not Sent','Partially Sent']
def can_send_now(self):
hold_queue = (cint(frappe.defaults.get_defaults().get("hold_queue"))==1)
if frappe.are_emails_muted() or not self.is_to_be_sent() or hold_queue:
return False
return True
def send(self, is_background_task=False):
""" Send emails to recipients.
"""
if not self.can_send_now():
frappe.db.rollback()
return
with SendMailContext(self, is_background_task) as ctx:
message = None
for recipient in self.recipients:
if not recipient.is_mail_to_be_sent():
continue
message = ctx.build_message(recipient.recipient)
if not frappe.flags.in_test:
ctx.smtp_session.sendmail(recipient.recipient, self.sender, message)
ctx.add_to_sent_list(recipient)
if frappe.flags.in_test:
frappe.flags.sent_mail = message
return
if ctx.email_account_doc.append_emails_to_sent_folder and ctx.sent_to:
ctx.email_account_doc.append_email_to_sent_folder(message)
@task(queue = 'short')
def send_mail(email_queue_name, is_background_task=False):
"""This is equalent to EmqilQueue.send.
This provides a way to make sending mail as a background job.
"""
record = EmailQueue.find(email_queue_name)
record.send(is_background_task=is_background_task)
class SendMailContext:
def __init__(self, queue_doc: Document, is_background_task: bool = False):
self.queue_doc = queue_doc
self.is_background_task = is_background_task
self.email_account_doc = queue_doc.get_email_account()
self.smtp_server = self.email_account_doc.get_smtp_server()
self.sent_to = [rec.recipient for rec in self.queue_doc.recipients if rec.is_main_sent()]
def __enter__(self):
self.queue_doc.update_status(status='Sending', commit=True)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
exceptions = [
smtplib.SMTPServerDisconnected,
smtplib.SMTPAuthenticationError,
smtplib.SMTPRecipientsRefused,
smtplib.SMTPConnectError,
smtplib.SMTPHeloError,
JobTimeoutException
]
self.smtp_server.quit()
self.log_exception(exc_type, exc_val, exc_tb)
if exc_type in exceptions:
email_status = (self.sent_to and 'Partially Sent') or 'Not Sent'
self.queue_doc.update_status(status = email_status, commit = True)
elif exc_type:
if self.queue_doc.retry < MAX_RETRY_COUNT:
update_fields = {'status': 'Not Sent', 'retry': self.queue_doc.retry + 1}
else:
update_fields = {'status': (self.sent_to and 'Partially Errored') or 'Error'}
self.queue_doc.update_status(**update_fields, commit = True)
else:
email_status = self.is_mail_sent_to_all() and 'Sent'
email_status = email_status or (self.sent_to and 'Partially Sent') or 'Not Sent'
self.queue_doc.update_status(status = email_status, commit = True)
def log_exception(self, exc_type, exc_val, exc_tb):
if exc_type:
traceback_string = "".join(traceback.format_tb(exc_tb))
traceback_string += f"\n Queue Name: {self.queue_doc.name}"
if self.is_background_task:
frappe.log_error(title = 'frappe.email.queue.flush', message = traceback_string)
else:
frappe.log_error(message = traceback_string)
@property
def smtp_session(self):
if frappe.flags.in_test:
return
return self.smtp_server.session
def add_to_sent_list(self, recipient):
# Update recipient status
recipient.update_db(status='Sent', commit=True)
self.sent_to.append(recipient.recipient)
def is_mail_sent_to_all(self):
return sorted(self.sent_to) == sorted([rec.recipient for rec in self.queue_doc.recipients])
def get_message_object(self, message):
return Parser(policy=SMTPUTF8).parsestr(message)
def message_placeholder(self, placeholder_key):
map = {
'tracker': '<!--email open check-->',
'unsubscribe_url': '<!--unsubscribe url-->',
'cc': '<!--cc message-->',
'recipient': '<!--recipient-->',
}
return map.get(placeholder_key)
def build_message(self, recipient_email):
"""Build message specific to the recipient.
"""
message = self.queue_doc.message
if not message:
return ""
message = message.replace(self.message_placeholder('tracker'), self.get_tracker_str())
message = message.replace(self.message_placeholder('unsubscribe_url'),
self.get_unsubscribe_str(recipient_email))
message = message.replace(self.message_placeholder('cc'), self.get_receivers_str())
message = message.replace(self.message_placeholder('recipient'),
self.get_receipient_str(recipient_email))
message = self.include_attachments(message)
return message
def get_tracker_str(self):
tracker_url_html = \
'<img src="https://{}/api/method/frappe.core.doctype.communication.email.mark_email_as_seen?name={}"/>'
message = ''
if frappe.conf.use_ssl and self.queue_doc.track_email_status:
message = quopri.encodestring(
tracker_url_html.format(frappe.local.site, self.queue_doc.communication).encode()
).decode()
return message
def get_unsubscribe_str(self, recipient_email):
unsubscribe_url = ''
if self.queue_doc.add_unsubscribe_link and self.queue_doc.reference_doctype:
doctype, doc_name = self.queue_doc.reference_doctype, self.queue_doc.reference_name
unsubscribe_url = get_unsubcribed_url(doctype, doc_name, recipient_email,
self.queue_doc.unsubscribe_method, self.queue_doc.unsubscribe_param)
return quopri.encodestring(unsubscribe_url.encode()).decode()
def get_receivers_str(self):
message = ''
if self.queue_doc.expose_recipients == "footer":
to_str = ', '.join(self.queue_doc.to)
cc_str = ', '.join(self.queue_doc.cc)
message = f"This email was sent to {to_str}"
message = message + f" and copied to {cc_str}" if cc_str else message
return message
def get_receipient_str(self, recipient_email):
message = ''
if self.queue_doc.expose_recipients != "header":
message = recipient_email
return message
def include_attachments(self, message):
message_obj = self.get_message_object(message)
attachments = self.queue_doc.attachments_list
for attachment in attachments:
if attachment.get('fcontent'):
continue
fid = attachment.get("fid")
if fid:
_file = frappe.get_doc("File", fid)
fcontent = _file.get_content()
attachment.update({
'fname': _file.file_name,
'fcontent': fcontent,
'parent': message_obj
})
attachment.pop("fid", None)
add_attachment(**attachment)
elif attachment.get("print_format_attachment") == 1:
attachment.pop("print_format_attachment", None)
print_format_file = frappe.attach_print(**attachment)
print_format_file.update({"parent": message_obj})
add_attachment(**print_format_file)
return safe_encode(message_obj.as_string())
@frappe.whitelist()
def retry_sending(name):
doc = frappe.get_doc("Email Queue", name)
@ -42,7 +288,9 @@ def retry_sending(name):
@frappe.whitelist()
def send_now(name):
send_one(name, now=True)
record = EmailQueue.find(name)
if record:
record.send()
def on_doctype_update():
"""Add index in `tabCommunication` for `(reference_doctype, reference_name)`"""

View file

@ -7,4 +7,16 @@ import frappe
from frappe.model.document import Document
class EmailQueueRecipient(Document):
pass
DOCTYPE = 'Email Queue Recipient'
def is_mail_to_be_sent(self):
return self.status == 'Not Sent'
def is_main_sent(self):
return self.status == 'Sent'
def update_db(self, commit=False, **kwargs):
frappe.db.set_value(self.DOCTYPE, self.name, kwargs)
if commit:
frappe.db.commit()

View file

@ -102,7 +102,8 @@
"default": "0",
"fieldname": "is_standard",
"fieldtype": "Check",
"label": "Is Standard"
"label": "Is Standard",
"no_copy": 1
},
{
"depends_on": "is_standard",
@ -281,7 +282,7 @@
"icon": "fa fa-envelope",
"index_web_pages_for_search": 1,
"links": [],
"modified": "2020-11-24 14:25:43.245677",
"modified": "2021-05-04 11:17:11.882314",
"modified_by": "Administrator",
"module": "Email",
"name": "Notification",

View file

@ -4,7 +4,7 @@
from __future__ import unicode_literals
import frappe, re, os
from frappe.utils.pdf import get_pdf
from frappe.email.smtp import get_outgoing_email_account
from frappe.email.doctype.email_account.email_account import EmailAccount
from frappe.utils import (get_url, scrub_urls, strip, expand_relative_urls, cint,
split_emails, to_markdown, markdown, random_string, parse_addr)
import email.utils
@ -75,7 +75,8 @@ class EMail:
self.bcc = bcc or []
self.html_set = False
self.email_account = email_account or get_outgoing_email_account(sender=sender)
self.email_account = email_account or \
EmailAccount.find_outgoing(match_by_email=sender, _raise_error=True)
def set_html(self, message, text_content = None, footer=None, print_html=None,
formatted=None, inline_images=None, header=None):
@ -249,8 +250,8 @@ class EMail:
def get_formatted_html(subject, message, footer=None, print_html=None,
email_account=None, header=None, unsubscribe_link=None, sender=None, with_container=False):
if not email_account:
email_account = get_outgoing_email_account(False, sender=sender)
email_account = email_account or EmailAccount.find_outgoing(match_by_email=sender)
signature = None
if "<!-- signature-included -->" not in message:
@ -480,4 +481,4 @@ def sanitize_email_header(str):
return str.replace('\r', '').replace('\n', '')
def get_brand_logo(email_account):
return email_account.get('brand_logo')
return email_account.get('brand_logo')

View file

@ -7,7 +7,8 @@ import sys
from six.moves import html_parser as HTMLParser
import smtplib, quopri, json
from frappe import msgprint, _, safe_decode, safe_encode, enqueue
from frappe.email.smtp import SMTPServer, get_outgoing_email_account
from frappe.email.smtp import SMTPServer
from frappe.email.doctype.email_account.email_account import EmailAccount
from frappe.email.email_body import get_email, get_formatted_html, add_attachment
from frappe.utils.verified_command import get_signed_params, verify_request
from html2text import html2text
@ -73,7 +74,9 @@ def send(recipients=None, sender=None, subject=None, message=None, text_content=
if isinstance(send_after, int):
send_after = add_days(nowdate(), send_after)
email_account = get_outgoing_email_account(True, append_to=reference_doctype, sender=sender)
email_account = EmailAccount.find_outgoing(
match_by_doctype=reference_doctype, match_by_email=sender, _raise_error=True)
if not sender or sender == "Administrator":
sender = email_account.default_sender
@ -170,19 +173,19 @@ def add(recipients, sender, subject, **kwargs):
if not email_queue:
email_queue = get_email_queue([r], sender, subject, **kwargs)
if kwargs.get('now'):
send_one(email_queue.name, now=True)
email_queue.send()
else:
duplicate = email_queue.get_duplicate([r])
duplicate.insert(ignore_permissions=True)
if kwargs.get('now'):
send_one(duplicate.name, now=True)
duplicate.send()
frappe.db.commit()
else:
email_queue = get_email_queue(recipients, sender, subject, **kwargs)
if kwargs.get('now'):
send_one(email_queue.name, now=True)
email_queue.send()
def get_email_queue(recipients, sender, subject, **kwargs):
'''Make Email Queue object'''
@ -234,6 +237,9 @@ def get_email_queue(recipients, sender, subject, **kwargs):
', '.join(mail.recipients), traceback.format_exc()), 'Email Not Sent')
recipients = list(set(recipients + kwargs.get('cc', []) + kwargs.get('bcc', [])))
email_account = kwargs.get('email_account')
email_account_name = email_account and email_account.is_exists_in_db() and email_account.name
e.set_recipients(recipients)
e.reference_doctype = kwargs.get('reference_doctype')
e.reference_name = kwargs.get('reference_name')
@ -245,8 +251,8 @@ def get_email_queue(recipients, sender, subject, **kwargs):
e.send_after = kwargs.get('send_after')
e.show_as_cc = ",".join(kwargs.get('cc', []))
e.show_as_bcc = ",".join(kwargs.get('bcc', []))
e.email_account = email_account_name or None
e.insert(ignore_permissions=True)
return e
def get_emails_sent_this_month():
@ -328,44 +334,25 @@ def return_unsubscribed_page(email, doctype, name):
indicator_color='green')
def flush(from_test=False):
"""flush email queue, every time: called from scheduler"""
# additional check
auto_commit = not from_test
"""flush email queue, every time: called from scheduler
"""
from frappe.email.doctype.email_queue.email_queue import send_mail
# To avoid running jobs inside unit tests
if frappe.are_emails_muted():
msgprint(_("Emails are muted"))
from_test = True
smtpserver_dict = frappe._dict()
if cint(frappe.defaults.get_defaults().get("hold_queue"))==1:
return
for email in get_queue():
for row in get_queue():
try:
func = send_mail if from_test else send_mail.enqueue
is_background_task = not from_test
func(email_queue_name = row.name, is_background_task = is_background_task)
except Exception:
frappe.log_error()
if cint(frappe.defaults.get_defaults().get("hold_queue"))==1:
break
if email.name:
smtpserver = smtpserver_dict.get(email.sender)
if not smtpserver:
smtpserver = SMTPServer()
smtpserver_dict[email.sender] = smtpserver
if from_test:
send_one(email.name, smtpserver, auto_commit)
else:
send_one_args = {
'email': email.name,
'smtpserver': smtpserver,
'auto_commit': auto_commit,
}
enqueue(
method = 'frappe.email.queue.send_one',
queue = 'short',
**send_one_args
)
# NOTE: removing commit here because we pass auto_commit
# finally:
# frappe.db.commit()
def get_queue():
return frappe.db.sql('''select
name, sender
@ -378,213 +365,6 @@ def get_queue():
by priority desc, creation asc
limit 500''', { 'now': now_datetime() }, as_dict=True)
def send_one(email, smtpserver=None, auto_commit=True, now=False):
'''Send Email Queue with given smtpserver'''
email = frappe.db.sql('''select
name, status, communication, message, sender, reference_doctype,
reference_name, unsubscribe_param, unsubscribe_method, expose_recipients,
show_as_cc, add_unsubscribe_link, attachments, retry
from
`tabEmail Queue`
where
name=%s
for update''', email, as_dict=True)
if len(email):
email = email[0]
else:
return
recipients_list = frappe.db.sql('''select name, recipient, status from
`tabEmail Queue Recipient` where parent=%s''', email.name, as_dict=1)
if frappe.are_emails_muted():
frappe.msgprint(_("Emails are muted"))
return
if cint(frappe.defaults.get_defaults().get("hold_queue"))==1 :
return
if email.status not in ('Not Sent','Partially Sent') :
# rollback to release lock and return
frappe.db.rollback()
return
frappe.db.sql("""update `tabEmail Queue` set status='Sending', modified=%s where name=%s""",
(now_datetime(), email.name), auto_commit=auto_commit)
if email.communication:
frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit)
email_sent_to_any_recipient = None
try:
message = None
if not frappe.flags.in_test:
if not smtpserver:
smtpserver = SMTPServer()
# to avoid always using default email account for outgoing
if getattr(frappe.local, "outgoing_email_account", None):
frappe.local.outgoing_email_account = {}
smtpserver.setup_email_account(email.reference_doctype, sender=email.sender)
for recipient in recipients_list:
if recipient.status != "Not Sent":
continue
message = prepare_message(email, recipient.recipient, recipients_list)
if not frappe.flags.in_test:
smtpserver.sess.sendmail(email.sender, recipient.recipient, message)
recipient.status = "Sent"
frappe.db.sql("""update `tabEmail Queue Recipient` set status='Sent', modified=%s where name=%s""",
(now_datetime(), recipient.name), auto_commit=auto_commit)
email_sent_to_any_recipient = any("Sent" == s.status for s in recipients_list)
#if all are sent set status
if email_sent_to_any_recipient:
frappe.db.sql("""update `tabEmail Queue` set status='Sent', modified=%s where name=%s""",
(now_datetime(), email.name), auto_commit=auto_commit)
else:
frappe.db.sql("""update `tabEmail Queue` set status='Error', error=%s
where name=%s""", ("No recipients to send to", email.name), auto_commit=auto_commit)
if frappe.flags.in_test:
frappe.flags.sent_mail = message
return
if email.communication:
frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit)
if smtpserver.append_emails_to_sent_folder and email_sent_to_any_recipient:
smtpserver.email_account.append_email_to_sent_folder(message)
except (smtplib.SMTPServerDisconnected,
smtplib.SMTPConnectError,
smtplib.SMTPHeloError,
smtplib.SMTPAuthenticationError,
smtplib.SMTPRecipientsRefused,
JobTimeoutException):
# bad connection/timeout, retry later
if email_sent_to_any_recipient:
frappe.db.sql("""update `tabEmail Queue` set status='Partially Sent', modified=%s where name=%s""",
(now_datetime(), email.name), auto_commit=auto_commit)
else:
frappe.db.sql("""update `tabEmail Queue` set status='Not Sent', modified=%s where name=%s""",
(now_datetime(), email.name), auto_commit=auto_commit)
if email.communication:
frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit)
# no need to attempt further
return
except Exception as e:
frappe.db.rollback()
if email.retry < 3:
frappe.db.sql("""update `tabEmail Queue` set status='Not Sent', modified=%s, retry=retry+1 where name=%s""",
(now_datetime(), email.name), auto_commit=auto_commit)
else:
if email_sent_to_any_recipient:
frappe.db.sql("""update `tabEmail Queue` set status='Partially Errored', error=%s where name=%s""",
(text_type(e), email.name), auto_commit=auto_commit)
else:
frappe.db.sql("""update `tabEmail Queue` set status='Error', error=%s
where name=%s""", (text_type(e), email.name), auto_commit=auto_commit)
if email.communication:
frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit)
if now:
print(frappe.get_traceback())
raise e
else:
# log to Error Log
frappe.log_error('frappe.email.queue.flush')
def prepare_message(email, recipient, recipients_list):
message = email.message
if not message:
return ""
# Parse "Email Account" from "Email Sender"
email_account = get_outgoing_email_account(raise_exception_not_set=False, sender=email.sender)
if frappe.conf.use_ssl and email_account.track_email_status:
# Using SSL => Publically available domain => Email Read Reciept Possible
message = message.replace("<!--email open check-->", quopri.encodestring('<img src="https://{}/api/method/frappe.core.doctype.communication.email.mark_email_as_seen?name={}"/>'.format(frappe.local.site, email.communication).encode()).decode())
else:
# No SSL => No Email Read Reciept
message = message.replace("<!--email open check-->", quopri.encodestring("".encode()).decode())
if email.add_unsubscribe_link and email.reference_doctype: # is missing the check for unsubscribe message but will not add as there will be no unsubscribe url
unsubscribe_url = get_unsubcribed_url(email.reference_doctype, email.reference_name, recipient,
email.unsubscribe_method, email.unsubscribe_params)
message = message.replace("<!--unsubscribe url-->", quopri.encodestring(unsubscribe_url.encode()).decode())
if email.expose_recipients == "header":
pass
else:
if email.expose_recipients == "footer":
if isinstance(email.show_as_cc, string_types):
email.show_as_cc = email.show_as_cc.split(",")
email_sent_to = [r.recipient for r in recipients_list]
email_sent_cc = ", ".join([e for e in email_sent_to if e in email.show_as_cc])
email_sent_to = ", ".join([e for e in email_sent_to if e not in email.show_as_cc])
if email_sent_cc:
email_sent_message = _("This email was sent to {0} and copied to {1}").format(email_sent_to,email_sent_cc)
else:
email_sent_message = _("This email was sent to {0}").format(email_sent_to)
message = message.replace("<!--cc message-->", quopri.encodestring(email_sent_message.encode()).decode())
message = message.replace("<!--recipient-->", recipient)
message = (message and message.encode('utf8')) or ''
message = safe_decode(message)
if PY3:
from email.policy import SMTPUTF8
message = Parser(policy=SMTPUTF8).parsestr(message)
else:
message = Parser().parsestr(message)
if email.attachments:
# On-demand attachments
attachments = json.loads(email.attachments)
for attachment in attachments:
if attachment.get('fcontent'):
continue
fid = attachment.get("fid")
if fid:
_file = frappe.get_doc("File", fid)
fcontent = _file.get_content()
attachment.update({
'fname': _file.file_name,
'fcontent': fcontent,
'parent': message
})
attachment.pop("fid", None)
add_attachment(**attachment)
elif attachment.get("print_format_attachment") == 1:
attachment.pop("print_format_attachment", None)
print_format_file = frappe.attach_print(**attachment)
print_format_file.update({"parent": message})
add_attachment(**print_format_file)
return safe_encode(message.as_string())
def clear_outbox(days=None):
"""Remove low priority older than 31 days in Outbox or configured in Log Settings.
Note: Used separate query to avoid deadlock

View file

@ -9,11 +9,24 @@ import _socket, sys
from frappe import _
from frappe.utils import cint, cstr, parse_addr
CONNECTION_FAILED = _('Could not connect to outgoing email server')
AUTH_ERROR_TITLE = _("Invalid Credentials")
AUTH_ERROR = _("Incorrect email or password. Please check your login credentials.")
SOCKET_ERROR_TITLE = _("Incorrect Configuration")
SOCKET_ERROR = _("Invalid Outgoing Mail Server or Port")
SEND_MAIL_FAILED = _("Unable to send emails at this time")
EMAIL_ACCOUNT_MISSING = _('Email Account not setup. Please create a new Email Account from Setup > Email > Email Account')
class InvalidEmailCredentials(frappe.ValidationError):
pass
def send(email, append_to=None, retry=1):
"""Deprecated: Send the message or add it to Outbox Email"""
def _send(retry):
from frappe.email.doctype.email_account.email_account import EmailAccount
try:
smtpserver = SMTPServer(append_to=append_to)
email_account = EmailAccount.find_outgoing(match_by_doctype=append_to)
smtpserver = email_account.get_smtp_server()
# validate is called in as_string
email_body = email.as_string()
@ -34,224 +47,80 @@ def send(email, append_to=None, retry=1):
_send(retry)
def get_outgoing_email_account(raise_exception_not_set=True, append_to=None, sender=None):
"""Returns outgoing email account based on `append_to` or the default
outgoing account. If default outgoing account is not found, it will
try getting settings from `site_config.json`."""
sender_email_id = None
_email_account = None
if sender:
sender_email_id = parse_addr(sender)[1]
if not getattr(frappe.local, "outgoing_email_account", None):
frappe.local.outgoing_email_account = {}
if not (frappe.local.outgoing_email_account.get(append_to)
or frappe.local.outgoing_email_account.get(sender_email_id)
or frappe.local.outgoing_email_account.get("default")):
email_account = None
if sender_email_id:
# check if the sender has an email account with enable_outgoing
email_account = _get_email_account({"enable_outgoing": 1,
"email_id": sender_email_id})
if not email_account and append_to:
# append_to is only valid when enable_incoming is checked
email_accounts = frappe.db.get_values("Email Account", {
"enable_outgoing": 1,
"enable_incoming": 1,
"append_to": append_to,
}, cache=True)
if email_accounts:
_email_account = email_accounts[0]
else:
email_account = _get_email_account({
"enable_outgoing": 1,
"enable_incoming": 1,
"append_to": append_to
})
if not email_account:
# sender don't have the outging email account
sender_email_id = None
email_account = get_default_outgoing_email_account(raise_exception_not_set=raise_exception_not_set)
if not email_account and _email_account:
# if default email account is not configured then setup first email account based on append to
email_account = _email_account
if not email_account and raise_exception_not_set and cint(frappe.db.get_single_value('System Settings', 'setup_complete')):
frappe.throw(_("Please setup default Email Account from Setup > Email > Email Account"),
frappe.OutgoingEmailError)
if email_account:
if email_account.enable_outgoing and not getattr(email_account, 'from_site_config', False):
raise_exception = True
if email_account.smtp_server in ['localhost','127.0.0.1'] or email_account.no_smtp_authentication:
raise_exception = False
email_account.password = email_account.get_password(raise_exception=raise_exception)
email_account.default_sender = email.utils.formataddr((email_account.name, email_account.get("email_id")))
frappe.local.outgoing_email_account[append_to or sender_email_id or "default"] = email_account
return frappe.local.outgoing_email_account.get(append_to) \
or frappe.local.outgoing_email_account.get(sender_email_id) \
or frappe.local.outgoing_email_account.get("default")
def get_default_outgoing_email_account(raise_exception_not_set=True):
'''conf should be like:
{
"mail_server": "smtp.example.com",
"mail_port": 587,
"use_tls": 1,
"mail_login": "emails@example.com",
"mail_password": "Super.Secret.Password",
"auto_email_id": "emails@example.com",
"email_sender_name": "Example Notifications",
"always_use_account_email_id_as_sender": 0,
"always_use_account_name_as_sender_name": 0
}
'''
email_account = _get_email_account({"enable_outgoing": 1, "default_outgoing": 1})
if email_account:
email_account.password = email_account.get_password(raise_exception=False)
if not email_account and frappe.conf.get("mail_server"):
# from site_config.json
email_account = frappe.new_doc("Email Account")
email_account.update({
"smtp_server": frappe.conf.get("mail_server"),
"smtp_port": frappe.conf.get("mail_port"),
# legacy: use_ssl was used in site_config instead of use_tls, but meant the same thing
"use_tls": cint(frappe.conf.get("use_tls") or 0) or cint(frappe.conf.get("use_ssl") or 0),
"login_id": frappe.conf.get("mail_login"),
"email_id": frappe.conf.get("auto_email_id") or frappe.conf.get("mail_login") or 'notifications@example.com',
"password": frappe.conf.get("mail_password"),
"always_use_account_email_id_as_sender": frappe.conf.get("always_use_account_email_id_as_sender", 0),
"always_use_account_name_as_sender_name": frappe.conf.get("always_use_account_name_as_sender_name", 0)
})
email_account.from_site_config = True
email_account.name = frappe.conf.get("email_sender_name") or "Frappe"
if not email_account and not raise_exception_not_set:
return None
if frappe.are_emails_muted():
# create a stub
email_account = frappe.new_doc("Email Account")
email_account.update({
"email_id": "notifications@example.com"
})
return email_account
def _get_email_account(filters):
name = frappe.db.get_value("Email Account", filters)
return frappe.get_doc("Email Account", name) if name else None
class SMTPServer:
def __init__(self, login=None, password=None, server=None, port=None, use_tls=None, use_ssl=None, append_to=None):
# get defaults from mail settings
def __init__(self, server, login=None, password=None, port=None, use_tls=None, use_ssl=None):
self.login = login
self.password = password
self._server = server
self._port = port
self.use_tls = use_tls
self.use_ssl = use_ssl
self._session = None
self._sess = None
self.email_account = None
self.server = None
self.append_emails_to_sent_folder = None
if server:
self.server = server
self.port = port
self.use_tls = cint(use_tls)
self.use_ssl = cint(use_ssl)
self.login = login
self.password = password
else:
self.setup_email_account(append_to)
def setup_email_account(self, append_to=None, sender=None):
self.email_account = get_outgoing_email_account(raise_exception_not_set=False, append_to=append_to, sender=sender)
if self.email_account:
self.server = self.email_account.smtp_server
self.login = (getattr(self.email_account, "login_id", None) or self.email_account.email_id)
if not self.email_account.no_smtp_authentication:
if self.email_account.ascii_encode_password:
self.password = frappe.safe_encode(self.email_account.password, 'ascii')
else:
self.password = self.email_account.password
else:
self.password = None
self.port = self.email_account.smtp_port
self.use_tls = self.email_account.use_tls
self.sender = self.email_account.email_id
self.use_ssl = self.email_account.use_ssl_for_outgoing
self.append_emails_to_sent_folder = self.email_account.append_emails_to_sent_folder
self.always_use_account_email_id_as_sender = cint(self.email_account.get("always_use_account_email_id_as_sender"))
self.always_use_account_name_as_sender_name = cint(self.email_account.get("always_use_account_name_as_sender_name"))
if not self.server:
frappe.msgprint(EMAIL_ACCOUNT_MISSING, raise_exception=frappe.OutgoingEmailError)
@property
def sess(self):
"""get session"""
if self._sess:
return self._sess
def port(self):
port = self._port or (self.use_ssl and 465) or (self.use_tls and 587)
return cint(port)
# check if email server specified
if not getattr(self, 'server'):
err_msg = _('Email Account not setup. Please create a new Email Account from Setup > Email > Email Account')
frappe.msgprint(err_msg)
raise frappe.OutgoingEmailError(err_msg)
@property
def server(self):
return cstr(self._server or "")
def secure_session(self, conn):
"""Secure the connection incase of TLS.
"""
if self.use_tls:
conn.ehlo()
conn.starttls()
conn.ehlo()
@property
def session(self):
if self.is_session_active():
return self._session
SMTP = smtplib.SMTP_SSL if self.use_ssl else smtplib.SMTP
try:
if self.use_ssl:
if not self.port:
self.port = 465
self._sess = smtplib.SMTP_SSL((self.server or ""), cint(self.port))
else:
if self.use_tls and not self.port:
self.port = 587
self._sess = smtplib.SMTP(cstr(self.server or ""),
cint(self.port) or None)
if not self._sess:
err_msg = _('Could not connect to outgoing email server')
frappe.msgprint(err_msg)
raise frappe.OutgoingEmailError(err_msg)
if self.use_tls:
self._sess.ehlo()
self._sess.starttls()
self._sess.ehlo()
self._session = SMTP(self.server, self.port)
if not self._session:
frappe.msgprint(CONNECTION_FAILED, raise_exception=frappe.OutgoingEmailError)
self.secure_session(self._session)
if self.login and self.password:
ret = self._sess.login(str(self.login or ""), str(self.password or ""))
res = self._session.login(str(self.login or ""), str(self.password or ""))
# check if logged correctly
if ret[0]!=235:
frappe.msgprint(ret[1])
raise frappe.OutgoingEmailError(ret[1])
if res[0]!=235:
frappe.msgprint(res[1], raise_exception=frappe.OutgoingEmailError)
return self._sess
return self._session
except smtplib.SMTPAuthenticationError as e:
from frappe.email.doctype.email_account.email_account import EmailAccount
EmailAccount.throw_invalid_credentials_exception()
self.throw_invalid_credentials_exception()
except _socket.error as e:
# Invalid mail server -- due to refusing connection
frappe.throw(
_("Invalid Outgoing Mail Server or Port"),
exc=frappe.ValidationError,
title=_("Incorrect Configuration")
)
frappe.throw(SOCKET_ERROR, title=SOCKET_ERROR_TITLE)
except smtplib.SMTPException:
frappe.msgprint(_('Unable to send emails at this time'))
frappe.msgprint(SEND_MAIL_FAILED)
raise
def is_session_active(self):
if self._session:
try:
return self._session.noop()[0] == 250
except Exception:
return False
def quit(self):
if self.is_session_active():
self._session.quit()
@classmethod
def throw_invalid_credentials_exception(cls):
frappe.throw(AUTH_ERROR, title=AUTH_ERROR_TITLE, exc=InvalidEmailCredentials)

View file

@ -7,10 +7,10 @@ from frappe import safe_decode
from frappe.email.receive import Email
from frappe.email.email_body import (replace_filename_with_cid,
get_email, inline_style_in_html, get_header)
from frappe.email.queue import prepare_message, get_email_queue
from frappe.email.queue import get_email_queue
from frappe.email.doctype.email_queue.email_queue import SendMailContext
from six import PY3
class TestEmailBody(unittest.TestCase):
def setUp(self):
email_html = '''
@ -57,7 +57,8 @@ This is the text version of this email
content='<h1>' + uni_chr1 + 'abcd' + uni_chr2 + '</h1>',
formatted='<h1>' + uni_chr1 + 'abcd' + uni_chr2 + '</h1>',
text_content='whatever')
result = prepare_message(email=email, recipient='test@test.com', recipients_list=[])
mail_ctx = SendMailContext(queue_doc = email)
result = mail_ctx.build_message(recipient_email = 'test@test.com')
self.assertTrue(b"<h1>=EA=80=80abcd=DE=B4</h1>" in result)
def test_prepare_message_returns_cr_lf(self):
@ -68,8 +69,10 @@ This is the text version of this email
content='<h1>\n this is a test of newlines\n' + '</h1>',
formatted='<h1>\n this is a test of newlines\n' + '</h1>',
text_content='whatever')
result = safe_decode(prepare_message(email=email,
recipient='test@test.com', recipients_list=[]))
mail_ctx = SendMailContext(queue_doc = email)
result = safe_decode(mail_ctx.build_message(recipient_email='test@test.com'))
if PY3:
self.assertTrue(result.count('\n') == result.count("\r"))
else:

View file

@ -4,7 +4,7 @@
import unittest
import frappe
from frappe.email.smtp import SMTPServer
from frappe.email.smtp import get_outgoing_email_account
from frappe.email.doctype.email_account.email_account import EmailAccount
class TestSMTP(unittest.TestCase):
def test_smtp_ssl_session(self):
@ -33,13 +33,13 @@ class TestSMTP(unittest.TestCase):
frappe.local.outgoing_email_account = {}
# lowest preference given to email account with default incoming enabled
create_email_account(email_id="default_outgoing_enabled@gmail.com", password="***", enable_outgoing = 1, default_outgoing=1)
self.assertEqual(get_outgoing_email_account().email_id, "default_outgoing_enabled@gmail.com")
create_email_account(email_id="default_outgoing_enabled@gmail.com", password="password", enable_outgoing = 1, default_outgoing=1)
self.assertEqual(EmailAccount.find_outgoing().email_id, "default_outgoing_enabled@gmail.com")
frappe.local.outgoing_email_account = {}
# highest preference given to email account with append_to matching
create_email_account(email_id="append_to@gmail.com", password="***", enable_outgoing = 1, default_outgoing=1, append_to="Blog Post")
self.assertEqual(get_outgoing_email_account(append_to="Blog Post").email_id, "append_to@gmail.com")
create_email_account(email_id="append_to@gmail.com", password="password", enable_outgoing = 1, default_outgoing=1, append_to="Blog Post")
self.assertEqual(EmailAccount.find_outgoing(match_by_doctype="Blog Post").email_id, "append_to@gmail.com")
# add back the mail_server
frappe.conf['mail_server'] = mail_server
@ -75,4 +75,4 @@ def make_server(port, ssl, tls):
use_tls = tls
)
server.sess
server.session

View file

@ -55,8 +55,8 @@ class EventProducer(Document):
self.reload()
def check_url(self):
if not frappe.utils.validate_url(self.producer_url):
frappe.throw(_('Invalid URL'))
valid_url_schemes = ("http", "https")
frappe.utils.validate_url(self.producer_url, throw=True, valid_schemes=valid_url_schemes)
# remove '/' from the end of the url like http://test_site.com/
# to prevent mismatch in get_url() results

View file

@ -228,10 +228,7 @@ def run_doc_method(method, docs=None, dt=None, dn=None, arg=None, args=None):
is_whitelisted(fn)
is_valid_http_method(fn)
try:
fnargs = inspect.getargspec(method_obj)[0]
except ValueError:
fnargs = inspect.getfullargspec(method_obj).args
fnargs = inspect.getfullargspec(method_obj).args
if not fnargs or (len(fnargs)==1 and fnargs[0]=="self"):
response = doc.run_method(method)

View file

@ -71,7 +71,8 @@ numeric_fieldtypes = (
data_field_options = (
'Email',
'Name',
'Phone'
'Phone',
'URL'
)
default_fields = (

View file

@ -666,6 +666,12 @@ class BaseDocument(object):
if data_field_options == "Phone":
frappe.utils.validate_phone_number(data, throw=True)
if data_field_options == "URL":
if not data:
continue
frappe.utils.validate_url(data, throw=True)
def _validate_constants(self):
if frappe.flags.in_import or self.is_new() or self.flags.ignore_validate_constants:
return

View file

@ -465,7 +465,7 @@ class DatabaseQuery(object):
elif f.operator.lower() in ('in', 'not in'):
values = f.value or ''
if isinstance(values, frappe.string_types):
if isinstance(values, str):
values = values.split(",")
fallback = "''"

View file

@ -1347,6 +1347,22 @@ class Document(BaseDocument):
from frappe.desk.doctype.tag.tag import DocTags
return DocTags(self.doctype).get_tags(self.name).split(",")[1:]
def __repr__(self):
name = self.name or "unsaved"
doctype = self.__class__.__name__
docstatus = f" docstatus={self.docstatus}" if self.docstatus else ""
parent = f" parent={self.parent}" if self.parent else ""
return f"<{doctype}: {name}{docstatus}{parent}>"
def __str__(self):
name = self.name or "unsaved"
doctype = self.__class__.__name__
return f"{doctype}({name})"
def execute_action(doctype, name, action, **kwargs):
"""Execute an action on a document (called by background worker)"""
doc = frappe.get_doc(doctype, name)

View file

@ -118,7 +118,7 @@ class Meta(Document):
# non standard list object, skip
continue
if (isinstance(value, (frappe.text_type, int, float, datetime, list, tuple))
if (isinstance(value, (str, int, float, datetime, list, tuple))
or (not no_nulls and value is None)):
out[key] = value

View file

@ -599,9 +599,10 @@ def get_client_scopes(client_id):
def get_userinfo(user):
picture = None
frappe_server_url = get_server_url()
valid_url_schemes = ("http", "https", "ftp", "ftps")
if user.user_image:
if frappe.utils.validate_url(user.user_image):
if frappe.utils.validate_url(user.user_image, valid_schemes=valid_url_schemes):
picture = user.user_image
else:
picture = frappe_server_url + "/" + user.user_image

View file

@ -336,3 +336,4 @@ frappe.patches.v13_0.remove_twilio_settings
frappe.patches.v12_0.rename_uploaded_files_with_proper_name
frappe.patches.v13_0.queryreport_columns
frappe.patches.v13_0.jinja_hook
frappe.patches.v13_0.update_notification_channel_if_empty

View file

@ -0,0 +1,15 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals
import frappe
def execute():
frappe.reload_doc("Email", "doctype", "Notification")
notifications = frappe.get_all('Notification', {'is_standard': 1}, {'name', 'channel'})
for notification in notifications:
if not notification.channel:
frappe.db.set_value("Notification", notification.name, "channel", "Email", update_modified=False)
frappe.db.commit()

View file

@ -114,8 +114,6 @@ frappe.Application = Class.extend({
dialog.get_close_btn().toggle(false);
});
this.setup_user_group_listeners();
// listen to build errors
this.setup_build_error_listener();
@ -593,15 +591,6 @@ frappe.Application = Class.extend({
}
},
setup_user_group_listeners() {
frappe.realtime.on('user_group_added', (user_group) => {
frappe.boot.user_groups && frappe.boot.user_groups.push(user_group);
});
frappe.realtime.on('user_group_deleted', (user_group) => {
frappe.boot.user_groups = (frappe.boot.user_groups || []).filter(el => el !== user_group);
});
},
setup_energy_point_listeners() {
frappe.realtime.on('energy_point_alert', (message) => {
frappe.show_alert(message);
@ -611,8 +600,7 @@ frappe.Application = Class.extend({
setup_copy_doc_listener() {
$('body').on('paste', (e) => {
try {
let clipboard_data = e.clipboardData || window.clipboardData || e.originalEvent.clipboardData;
let pasted_data = clipboard_data.getData('Text');
let pasted_data = frappe.utils.get_clipboard_data(e);
let doc = JSON.parse(pasted_data);
if (doc.doctype) {
e.preventDefault();
@ -627,6 +615,7 @@ frappe.Application = Class.extend({
let res = frappe.model.with_doctype(doc.doctype, () => {
let newdoc = frappe.model.copy_doc(doc);
newdoc.__newname = doc.name;
delete doc.name;
newdoc.idx = null;
newdoc.__run_link_triggers = false;
frappe.set_route('Form', newdoc.doctype, newdoc.name);

View file

@ -319,7 +319,7 @@ frappe.get_data_pill = (label, target_id=null, remove_action=null, image=null) =
frappe.get_modal = function(title, content) {
return $(`<div class="modal fade" style="overflow: auto;" tabindex="-1">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<div class="fill-width flex title-section">

View file

@ -90,16 +90,10 @@ frappe.ui.form.ControlAutocomplete = frappe.ui.form.ControlData.extend({
});
this.$input.on("awesomplete-open", () => {
this.toggle_container_scroll('.modal-dialog', 'modal-dialog-scrollable');
this.toggle_container_scroll('.grid-form-body .form-area', 'scrollable');
this.autocomplete_open = true;
});
this.$input.on("awesomplete-close", () => {
this.toggle_container_scroll('.modal-dialog', 'modal-dialog-scrollable', true);
this.toggle_container_scroll('.grid-form-body .form-area', 'scrollable', true);
this.autocomplete_open = false;
});

View file

@ -78,35 +78,25 @@ frappe.ui.form.ControlComment = frappe.ui.form.ControlTextEditor.extend({
},
get_mention_options() {
if (!(this.mentions && this.mentions.length)) {
if (!this.enable_mentions) {
return null;
}
const at_values = this.mentions.slice();
let me = this;
return {
allowedChars: /^[A-Za-z0-9_]*$/,
mentionDenotationChars: ["@"],
isolateCharacter: true,
source: function (searchTerm, renderList, mentionChar) {
let values;
if (mentionChar === "@") {
values = at_values;
}
if (searchTerm.length === 0) {
renderList(values, searchTerm);
} else {
const matches = [];
for (let i = 0; i < values.length; i++) {
if (~values[i].value.toLowerCase().indexOf(searchTerm.toLowerCase())) {
matches.push(values[i]);
}
}
renderList(matches, searchTerm);
}
},
source: frappe.utils.debounce(async function(search_term, renderList) {
let method = me.mention_search_method || 'frappe.desk.search.get_names_for_mentions';
let values = await frappe.xcall(method, {
search_term
});
renderList(values, search_term);
}, 300),
renderItem(item) {
let value = item.value;
return `${value} ${item.is_group ? frappe.utils.icon('users') : ''}`;
}
};
},

View file

@ -1,7 +1,7 @@
frappe.ui.form.ControlCurrency = frappe.ui.form.ControlFloat.extend({
format_for_input: function(value) {
var formatted_value = format_number(value, this.get_number_format(), this.get_precision());
return isNaN(parseFloat(value)) ? "" : formatted_value;
return isNaN(Number(value)) ? "" : formatted_value;
},
get_precision: function() {

View file

@ -18,11 +18,96 @@ frappe.ui.form.ControlData = frappe.ui.form.ControlInput.extend({
this.$input.attr("maxlength", this.df.length || 140);
}
this.$input.on('paste', (e) => {
let pasted_data = frappe.utils.get_clipboard_data(e);
let maxlength = this.$input.attr('maxlength');
if (maxlength && pasted_data.length > maxlength) {
let warning_message = __('The value you pasted was {0} characters long. Max allowed characters is {1}.', [
cstr(pasted_data.length).bold(),
cstr(maxlength).bold()
]);
// Only show edit link to users who can update the doctype
if (this.frm && frappe.model.can_write(this.frm.doctype)) {
let doctype_edit_link = null;
if (this.frm.meta.custom) {
doctype_edit_link = frappe.utils.get_form_link(
'DocType',
this.frm.doctype, true,
__('this form')
);
} else {
doctype_edit_link = frappe.utils.get_form_link('Customize Form', 'Customize Form', true, null, {
doc_type: this.frm.doctype
});
}
let edit_note = __('{0}: You can increase the limit for the field if required via {1}', [
__('Note').bold(),
doctype_edit_link
]);
warning_message += `<br><br><span class="text-muted text-small">${edit_note}</span>`;
}
frappe.msgprint({
message: warning_message,
indicator: 'orange',
title: __('Data Clipped')
});
}
});
this.set_input_attributes();
this.input = this.$input.get(0);
this.has_input = true;
this.bind_change_event();
this.setup_autoname_check();
if (this.df.options == 'URL') {
this.setup_url_field();
}
},
setup_url_field: function() {
this.$wrapper.find('.control-input').append(
`<span class="link-btn">
<a class="btn-open no-decoration" title="${__("Open Link")}" target="_blank">
${frappe.utils.icon('link-url', 'sm')}
</a>
</span>`
);
this.$link = this.$wrapper.find('.link-btn');
this.$link_open = this.$link.find('.btn-open');
this.$input.on("focus", () => {
setTimeout(() => {
let inputValue = this.get_input_value();
if (inputValue && validate_url(inputValue)) {
this.$link.toggle(true);
this.$link_open.attr('href', this.get_input_value());
}
}, 500);
});
this.$input.bind("input", () => {
let inputValue = this.get_input_value();
if (inputValue && validate_url(inputValue)) {
this.$link.toggle(true);
this.$link_open.attr('href', this.get_input_value());
} else {
this.$link.toggle(false);
}
});
this.$input.on("blur", () => {
// if this disappears immediately, the user's click
// does not register, hence timeout
setTimeout(() => {
this.$link.toggle(false);
}, 500);
});
},
bind_change_event: function() {
const change_handler = e => {
@ -126,6 +211,9 @@ frappe.ui.form.ControlData = frappe.ui.form.ControlInput.extend({
this.df.invalid = email_invalid;
return v;
}
} else if (this.df.options == 'URL') {
this.df.invalid = !validate_url(v);
return v;
} else {
return v;
}

View file

@ -10,7 +10,7 @@ frappe.ui.form.ControlFloat = frappe.ui.form.ControlInt.extend({
number_format = this.get_number_format();
}
var formatted_value = format_number(value, number_format, this.get_precision());
return isNaN(parseFloat(value)) ? "" : formatted_value;
return isNaN(Number(value)) ? "" : formatted_value;
},
get_number_format: function() {

View file

@ -241,16 +241,10 @@ frappe.ui.form.ControlLink = frappe.ui.form.ControlData.extend({
});
this.$input.on("awesomplete-open", () => {
this.toggle_container_scroll('.modal-dialog', 'modal-dialog-scrollable');
this.toggle_container_scroll('.grid-form-body .form-area', 'scrollable');
this.autocomplete_open = true;
});
this.$input.on("awesomplete-close", () => {
this.toggle_container_scroll('.modal-dialog', 'modal-dialog-scrollable', true);
this.toggle_container_scroll('.grid-form-body .form-area', 'scrollable', true);
this.autocomplete_open = false;
});

View file

@ -12,6 +12,7 @@ class MentionBlot extends Embed {
denotationChar.innerHTML = data.denotationChar;
node.appendChild(denotationChar);
node.innerHTML += data.value;
node.innerHTML += `${data.isGroup === 'true' ? frappe.utils.icon('users') : ''}`;
node.dataset.id = data.id;
node.dataset.value = data.value;
node.dataset.denotationChar = data.denotationChar;

View file

@ -26,8 +26,7 @@ frappe.ui.form.ControlTable = frappe.ui.form.Control.extend({
const row_docname = $(e.target).closest('.grid-row').data('name');
const in_grid_form = $(e.target).closest('.form-in-grid').length;
let clipboard_data = e.clipboardData || window.clipboardData || e.originalEvent.clipboardData;
let pasted_data = clipboard_data.getData('Text');
let pasted_data = frappe.utils.get_clipboard_data(e);
if (!pasted_data || in_grid_form) return;

View file

@ -24,7 +24,7 @@ frappe.ui.form.Footer = Class.extend({
parent: this.wrapper.find(".comment-box"),
render_input: true,
only_input: true,
mentions: frappe.utils.get_names_for_mentions(),
enable_mentions: true,
df: {
fieldtype: 'Comment',
fieldname: 'comment'

View file

@ -492,7 +492,7 @@ class FormTimeline extends BaseTimeline {
fieldname: 'comment',
label: 'Comment'
},
mentions: frappe.utils.get_names_for_mentions(),
enable_mentions: true,
render_input: true,
only_input: true,
no_wrapper: true

View file

@ -15,7 +15,10 @@ frappe.form.formatters = {
return "<div style='text-align: right'>" + value + "</div>";
}
},
Data: function(value) {
Data: function(value, df) {
if (df && df.options == "URL") {
return `<a href="${value}" title="Open Link" target="_blank">${value}</a>`;
}
return value==null ? "" : value;
},
Select: function(value) {
@ -156,7 +159,7 @@ frappe.form.formatters = {
return value || "";
},
DateRange: function(value) {
if($.isArray(value)) {
if (Array.isArray(value)) {
return __("{0} to {1}", [frappe.datetime.str_to_user(value[0]), frappe.datetime.str_to_user(value[1])]);
} else {
return value || "";

View file

@ -66,7 +66,7 @@ export default class GridRowForm {
</div>
</div>
<div class="grid-form-body">
<div class="form-area scrollable"></div>
<div class="form-area"></div>
<div class="grid-footer-toolbar hidden-xs flex justify-between">
<div class="grid-shortcuts">
<span> ${frappe.utils.icon("keyboard", "md")} </span>

View file

@ -32,7 +32,7 @@ frappe.ui.form.Toolbar = class Toolbar {
}
set_title() {
if (this.frm.is_new()) {
var title = __('New {0}', [this.frm.meta.name]);
var title = __('New {0}', [__(this.frm.meta.name)]);
} else if (this.frm.meta.title_field) {
let title_field = (this.frm.doc[this.frm.meta.title_field] || "").toString().trim();
var title = strip_html(title_field || this.frm.docname);
@ -551,7 +551,7 @@ frappe.ui.form.Toolbar = class Toolbar {
let fields = this.frm.fields
.filter(visible_fields_filter)
.map(f => ({ label: f.df.label, value: f.df.fieldname }));
.map(f => ({ label: __(f.df.label), value: f.df.fieldname }));
let dialog = new frappe.ui.Dialog({
title: __('Jump to field'),

View file

@ -150,13 +150,13 @@ frappe.views.ListViewSelect = class ListViewSelect {
const views_wrapper = this.sidebar.sidebar.find(".views-section");
views_wrapper.find(".sidebar-label").html(`${__(view)}`);
const $dropdown = views_wrapper.find(".views-dropdown");
let placeholder = `Select ${view}`;
let placeholder = `${__("Select {0}", [__(view)])}`;
let html = ``;
if (!items || !items.length) {
html = `<div class="empty-state">
${__("No {} Found", [view])}
${__("No {0} Found", [__(view)])}
</div>`;
} else {
const page_name = this.get_page_name();

View file

@ -5,7 +5,7 @@
<div class="tag-filters-area">
<div class="active-tag-filters">
<button class="btn btn-default btn-xs add-filter text-muted">
Add Filter
{{ __("Add Filter") }}
</button>
</div>
</div>
@ -71,12 +71,12 @@
</div>
<div v-if="requests.length == 0" class="no-result text-muted flex justify-center align-center" style="">
<div class="msg-box no-border" v-if="status.status == 'Inactive'" >
<p>Recorder is Inactive</p>
<p><button class="btn btn-primary btn-sm btn-new-doc" @click="start()">Start Recording</button></p>
<p>{{ __("Recorder is Inactive") }}</p>
<p><button class="btn btn-primary btn-sm btn-new-doc" @click="start()">{{ __("Start Recording") }}</button></p>
</div>
<div class="msg-box no-border" v-if="status.status == 'Active'" >
<p>No Requests found</p>
<p>Go make some noise</p>
<p>{{ __("No Requests found") }}</p>
<p>{{ __("Go make some noise") }}</p>
</div>
</div>
<div v-if="requests.length != 0" class="list-paging-area">
@ -108,12 +108,12 @@ export default {
return {
requests: [],
columns: [
{label: "Path", slug: "path"},
{label: "Duration (ms)", slug: "duration", sortable: true, number: true},
{label: "Time in Queries (ms)", slug: "time_queries", sortable: true, number: true},
{label: "Queries", slug: "queries", sortable: true, number: true},
{label: "Method", slug: "method"},
{label: "Time", slug: "time", sortable: true},
{label: __("Path"), slug: "path"},
{label: __("Duration (ms)"), slug: "duration", sortable: true, number: true},
{label: __("Time in Queries (ms)"), slug: "time_queries", sortable: true, number: true},
{label: __("Queries"), slug: "queries", sortable: true, number: true},
{label: __("Method"), slug: "method"},
{label: __("Time"), slug: "time", sortable: true},
],
query: {
sort: "duration",
@ -140,7 +140,7 @@ export default {
mounted() {
this.fetch_status();
this.refresh();
this.$root.page.set_secondary_action("Clear", () => {
this.$root.page.set_secondary_action(__("Clear"), () => {
frappe.set_route("recorder");
this.clear();
});
@ -151,11 +151,11 @@ export default {
const current_page = this.query.pagination.page;
const total_pages = this.query.pagination.total;
return [{
label: "First",
label: __("First"),
number: 1,
status: (current_page == 1) ? "disabled" : "",
},{
label: "Previous",
label: __("Previous"),
number: Math.max(current_page - 1, 1),
status: (current_page == 1) ? "disabled" : "",
}, {
@ -163,11 +163,11 @@ export default {
number: current_page,
status: "btn-info",
}, {
label: "Next",
label: __("Next"),
number: Math.min(current_page + 1, total_pages),
status: (current_page == total_pages) ? "disabled" : "",
}, {
label: "Last",
label: __("Last"),
number: total_pages,
status: (current_page == total_pages) ? "disabled" : "",
}];
@ -230,11 +230,11 @@ export default {
},
update_buttons: function() {
if(this.status.status == "Active") {
this.$root.page.set_primary_action("Stop", () => {
this.$root.page.set_primary_action(__("Stop"), () => {
this.stop();
});
} else {
this.$root.page.set_primary_action("Start", () => {
this.$root.page.set_primary_action(__("Start"), () => {
this.start();
});
}

View file

@ -16,7 +16,7 @@
</div>
<div class="row form-section visible-section">
<div class="col-sm-10">
<h6 class="form-section-heading uppercase">SQL Queries</h6>
<h6 class="form-section-heading uppercase">{{ __("SQL Queries") }}</h6>
</div>
<div class="col-sm-2 filter-list">
<div class="sort-selector">
@ -37,7 +37,7 @@
<div class="checkbox">
<label>
<span class="input-area"><input type="checkbox" class="input-with-feedback bold" data-fieldtype="Check" v-model="group_duplicates"></span>
<span class="label-area small">Group Duplicate Queries</span>
<span class="label-area small">{{ __("Group Duplicate Queries") }}</span>
</label>
</div>
</div>
@ -48,15 +48,15 @@
<div class="grid-row">
<div class="data-row row">
<div class="row-index col col-xs-1">
<span>Index</span></div>
<span>{{ __("Index") }}</span></div>
<div class="col grid-static-col col-xs-6">
<div class="static-area ellipsis">Query</div>
<div class="static-area ellipsis">{{ __("Query") }}</div>
</div>
<div class="col grid-static-col col-xs-2">
<div class="static-area ellipsis text-right">Duration (ms)</div>
<div class="static-area ellipsis text-right">{{ __("Duration (ms)") }}</div>
</div>
<div class="col grid-static-col col-xs-2">
<div class="static-area ellipsis text-right">Exact Copies</div>
<div class="static-area ellipsis text-right">{{ __("Exact Copies") }}</div>
</div>
</div>
</div>
@ -82,7 +82,7 @@
<div class="recorder-form-in-grid" v-if="showing == call.index">
<div class="grid-form-heading" @click="showing = null">
<div class="toolbar grid-header-toolbar">
<span class="panel-title">SQL Query #<span class="grid-form-row-index">{{ call.index }}</span></span>
<span class="panel-title">{{ __("SQL Query") }} #<span class="grid-form-row-index">{{ call.index }}</span></span>
<div class="btn btn-default btn-xs pull-right" style="margin-left: 7px;">
<span class="hidden-xs octicon octicon-triangle-up"></span>
</div>
@ -98,25 +98,25 @@
<form>
<div class="frappe-control">
<div class="form-group">
<div class="clearfix"><label class="control-label">Query</label></div>
<div class="clearfix"><label class="control-label">{{ __("Query") }}</label></div>
<div class="control-value like-disabled-input for-description"><pre>{{ call.query }}</pre></div>
</div>
</div>
<div class="frappe-control input-max-width">
<div class="form-group">
<div class="clearfix"><label class="control-label">Duration (ms)</label></div>
<div class="clearfix"><label class="control-label">{{ __("Duration (ms)") }}</label></div>
<div class="control-value like-disabled-input">{{ call.duration }}</div>
</div>
</div>
<div class="frappe-control input-max-width">
<div class="form-group">
<div class="clearfix"><label class="control-label">Exact Copies</label></div>
<div class="clearfix"><label class="control-label">{{ __("Exact Copies") }}</label></div>
<div class="control-value like-disabled-input">{{ call.exact_copies }}</div>
</div>
</div>
<div class="frappe-control">
<div class="form-group">
<div class="clearfix"><label class="control-label">Stack Trace</label></div>
<div class="clearfix"><label class="control-label"{{ __("Stack Trace") }}</label></div>
<div class="control-value like-disabled-input for-description" style="overflow:auto">
<table class="table table-striped">
<thead>
@ -137,7 +137,7 @@
</div>
<div class="frappe-control" v-if="call.explain_result[0]">
<div class="form-group">
<div class="clearfix"><label class="control-label">SQL Explain</label></div>
<div class="clearfix"><label class="control-label">{{ __("SQL Explain") }}</label></div>
<div class="control-value like-disabled-input for-description" style="overflow:auto">
<table class="table table-striped">
<thead>
@ -165,7 +165,7 @@
</div>
</div>
</div>
<div v-if="request.calls.length == 0" class="grid-empty text-center">No Data</div>
<div v-if="request.calls.length == 0" class="grid-empty text-center">{{ __("No Data") }}</div>
</div>
</div>
</div>
@ -201,19 +201,19 @@ export default {
data() {
return {
columns: [
{label: "Path", slug: "path", type: "Data", class: "col-sm-6"},
{label: "CMD", slug: "cmd", type: "Data", class: "col-sm-6"},
{label: "Time", slug: "time", type: "Time", class: "col-sm-6"},
{label: "Duration (ms)", slug: "duration", type: "Float", class: "col-sm-6"},
{label: "Number of Queries", slug: "queries", type: "Int", class: "col-sm-6"},
{label: "Time in Queries (ms)", slug: "time_queries", type: "Float", class: "col-sm-6"},
{label: "Request Headers", slug: "headers", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
{label: "Form Dict", slug: "form_dict", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
{label: __("Path"), slug: "path", type: "Data", class: "col-sm-6"},
{label: __("CMD"), slug: "cmd", type: "Data", class: "col-sm-6"},
{label: __("Time"), slug: "time", type: "Time", class: "col-sm-6"},
{label: __("Duration (ms)"), slug: "duration", type: "Float", class: "col-sm-6"},
{label: __("Number of Queries"), slug: "queries", type: "Int", class: "col-sm-6"},
{label: __("Time in Queries (ms)"), slug: "time_queries", type: "Float", class: "col-sm-6"},
{label: __("Request Headers"), slug: "headers", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
{label: __("Form Dict"), slug: "form_dict", type: "Small Text", formatter: value => `<pre class="for-description like-disabled-input">${JSON.stringify(value, null, 4)}</pre>`, class: "col-sm-12"},
],
table_columns: [
{label: "Execution Order", slug: "index", sortable: true},
{label: "Duration (ms)", slug: "duration", sortable: true},
{label: "Exact Copies", slug: "exact_copies", sortable: true},
{label: __("Execution Order"), slug: "index", sortable: true},
{label: __("Duration (ms)"), slug: "duration", sortable: true},
{label: __("Exact Copies"), slug: "exact_copies", sortable: true},
],
query: {
sort: "duration",
@ -236,11 +236,11 @@ export default {
const current_page = this.query.pagination.page;
const total_pages = this.query.pagination.total;
return [{
label: "First",
label: __("First"),
number: 1,
status: (current_page == 1) ? "disabled" : "",
},{
label: "Previous",
label: __("Previous"),
number: Math.max(current_page - 1, 1),
status: (current_page == 1) ? "disabled" : "",
}, {
@ -248,11 +248,11 @@ export default {
number: current_page,
status: "btn-info",
}, {
label: "Next",
label: __("Next"),
number: Math.min(current_page + 1, total_pages),
status: (current_page == total_pages) ? "disabled" : "",
}, {
label: "Last",
label: __("Last"),
number: total_pages,
status: (current_page == total_pages) ? "disabled" : "",
}];

View file

@ -6,6 +6,9 @@ import RecorderRoot from "./RecorderRoot.vue";
import RecorderDetail from "./RecorderDetail.vue";
import RequestDetail from "./RequestDetail.vue";
Vue.prototype.__ = window.__;
Vue.prototype.frappe = window.frappe;
Vue.use(VueRouter);
const routes = [
{

View file

@ -36,18 +36,6 @@ frappe.ui.FieldSelect = Class.extend({
var item = me.awesomplete.get_item(value);
me.$input.val(item.label);
});
this.$input.on("awesomplete-open", () => {
let modal = this.$input.parents('.modal-dialog')[0];
if (modal) {
$(modal).removeClass("modal-dialog-scrollable");
}
});
this.$input.on("awesomplete-close", () => {
let modal = this.$input.parents('.modal-dialog')[0];
if (modal) {
$(modal).addClass("modal-dialog-scrollable");
}
});
if(this.filter_fields) {
for(var i in this.filter_fields)

View file

@ -312,7 +312,7 @@ class NotificationsView extends BaseNotificationsView {
this.container.append($(`<div class="notification-null-state">
<div class="text-center">
<img src="/assets/frappe/images/ui-states/notification-empty-state.svg" alt="Generic Empty State" class="null-state">
<div class="title">No New notifications</div>
<div class="title">${__('No New notifications')}</div>
<div class="subtitle">
${__('Looks like you havent received any notifications.')}
</div></div></div>`));
@ -430,7 +430,7 @@ class EventsView extends BaseNotificationsView {
<div class="notification-null-state">
<div class="text-center">
<img src="/assets/frappe/images/ui-states/event-empty-state.svg" alt="Generic Empty State" class="null-state">
<div class="title">No Upcoming Events</div>
<div class="title">${__('No Upcoming Events')}</div>
<div class="subtitle">
${__('There are no upcoming events for you.')}
</div></div></div>

View file

@ -11,6 +11,34 @@ frappe.ui.ThemeSwitcher = class ThemeSwitcher {
title: __("Switch Theme")
});
this.body = $(`<div class="theme-grid"></div>`).appendTo(this.dialog.$body);
this.bind_events();
}
bind_events() {
this.dialog.$wrapper.on('keydown', (e) => {
if (!this.themes) return;
const key = frappe.ui.keys.get_key(e);
let increment_by;
if (key === "right") {
increment_by = 1;
} else if (key === "left") {
increment_by = -1;
} else {
return;
}
const current_index = this.themes.findIndex(theme => {
return theme.name === this.current_theme;
});
const new_theme = this.themes[current_index + increment_by];
if (!new_theme) return;
new_theme.$html.click();
return false;
});
}
refresh() {

View file

@ -52,6 +52,10 @@ window.validate_name = function(txt) {
return frappe.utils.validate_type(txt, "name");
};
window.validate_url = function(txt) {
return frappe.utils.validate_type(txt, "url");
};
window.nth = function(number) {
number = cint(number);
var s = 'th';

View file

@ -405,7 +405,7 @@ Object.assign(frappe.utils, {
regExp = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
break;
case "url":
regExp = /^(https?|s?ftp):\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!$&'()*+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.?)(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[$&'()*+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!$&'()*+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!$&'()*+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!$&'()*+,;=]|:|@)|\/|\?)*)?$/i;
regExp = /^((([A-Za-z0-9.+-]+:(?:\/\/)?)(?:[-;:&=\+\,\w]@)?[A-Za-z0-9.-]+(:[0-9]+)?|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)$/i;
break;
case "dateIso":
regExp = /^(\d{4})\D?(0[1-9]|1[0-2])\D?([12]\d|0[1-9]|3[01])$/;
@ -831,10 +831,13 @@ Object.assign(frappe.utils, {
if (callNow) func.apply(context, args);
};
},
get_form_link: function(doctype, name, html = false, display_text = null) {
get_form_link: function(doctype, name, html=false, display_text=null, query_params_obj=null) {
display_text = display_text || name;
name = encodeURIComponent(name);
const route = `/app/${encodeURIComponent(doctype.toLowerCase().replace(/ /g, '-'))}/${name}`;
let route = `/app/${encodeURIComponent(doctype.toLowerCase().replace(/ /g, '-'))}/${name}`;
if (query_params_obj) {
route += frappe.utils.make_query_string(query_params_obj);
}
if (html) {
return `<a href="${route}">${display_text}</a>`;
}
@ -1272,31 +1275,6 @@ Object.assign(frappe.utils, {
</div>`);
},
get_names_for_mentions() {
let names_for_mentions = Object.keys(frappe.boot.user_info || [])
.filter(user => {
return !["Administrator", "Guest"].includes(user)
&& frappe.boot.user_info[user].allowed_in_mentions
&& frappe.boot.user_info[user].user_type === 'System User';
})
.map(user => {
return {
id: frappe.boot.user_info[user].name,
value: frappe.boot.user_info[user].fullname,
};
});
frappe.boot.user_groups && frappe.boot.user_groups.map(group => {
names_for_mentions.push({
id: group,
value: group,
is_group: true,
link: frappe.utils.get_form_link('User Group', group)
});
});
return names_for_mentions;
},
print(doctype, docname, print_format, letterhead, lang_code) {
let w = window.open(
frappe.urllib.get_full_url(
@ -1319,5 +1297,11 @@ Object.assign(frappe.utils, {
frappe.msgprint(__('Please enable pop-ups'));
return;
}
},
get_clipboard_data(clipboard_paste_event) {
let e = clipboard_paste_event;
let clipboard_data = e.clipboardData || window.clipboardData || e.originalEvent.clipboardData;
return clipboard_data.getData('Text');
}
});

View file

@ -68,6 +68,8 @@ frappe.breadcrumbs = {
if (breadcrumbs.doctype && ["print", "form"].includes(view)) {
this.set_list_breadcrumb(breadcrumbs);
this.set_form_breadcrumb(breadcrumbs, view);
} else if (breadcrumbs.doctype && view === 'list') {
this.set_list_breadcrumb(breadcrumbs);
}
}

View file

@ -8,7 +8,7 @@ frappe.views.DashboardView = class DashboardView extends frappe.views.ListView {
setup_defaults() {
return super.setup_defaults()
.then(() => {
this.page_title = __('{0} Dashboard', [this.doctype]);
this.page_title = __('{0} Dashboard', [__(this.doctype)]);
this.dashboard_settings = frappe.get_user_settings(this.doctype)['dashboard_settings'] || null;
});
}
@ -271,7 +271,7 @@ frappe.views.DashboardView = class DashboardView extends frappe.views.ListView {
show_add_chart_dialog() {
let fields = this.get_field_options();
const dialog = new frappe.ui.Dialog({
title: __("Add a {0} Chart", [this.doctype]),
title: __("Add a {0} Chart", [__(this.doctype)]),
fields: [
{
fieldname: 'new_or_existing',

View file

@ -335,12 +335,12 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
let message;
if (dashboard_name) {
let dashboard_route_html = `<a href="#dashboard-view/${dashboard_name}">${dashboard_name}</a>`;
message = __("New {0} {1} added to Dashboard {2}", [doctype, name, dashboard_route_html]);
message = __("New {0} {1} added to Dashboard {2}", [__(doctype), name, dashboard_route_html]);
} else {
message = __("New {0} {1} created", [doctype, name]);
message = __("New {0} {1} created", [__(doctype), name]);
}
frappe.msgprint(message, __("New {0} Created", [doctype]));
frappe.msgprint(message, __("New {0} Created", [__(doctype)]));
});
}
@ -937,7 +937,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
else {
wrapper[0].innerHTML =
`<div class="flex justify-center align-center text-muted" style="height: 120px; display: flex;">
<div>Please select X and Y fields</div>
<div>${__("Please select X and Y fields")}</div>
</div>`;
}
}
@ -1094,7 +1094,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
return Object.assign(column, {
id: column.fieldname,
name: __(column.label),
name: __(column.label, null, `Column of report '${this.report_name}'`), // context has to match context in get_messages_from_report in translate.py
width: parseInt(column.width) || null,
editable: false,
compareValue: compareFn,
@ -1343,7 +1343,7 @@ frappe.views.QueryReport = class QueryReport extends frappe.views.BaseList {
open_url_post(frappe.request.url, args);
}
}, __('Export Report: '+ this.report_name), __('Download'));
}, __('Export Report: {0}', [this.report_name]), __('Download'));
}
get_data_for_csv(include_indentation) {

View file

@ -2,7 +2,6 @@ import Widget from "./base_widget.js";
frappe.provide("frappe.utils");
const INDICATOR_COLORS = ["Grey", "Green", "Red", "Orange", "Pink", "Yellow", "Blue", "Cyan", "Teal"];
export default class ShortcutWidget extends Widget {
constructor(opts) {
opts.shadow = true;
@ -79,7 +78,7 @@ export default class ShortcutWidget extends Widget {
this.action_area.empty();
const label = get_label();
let color = INDICATOR_COLORS.includes(this.color) && count ? this.color.toLowerCase() : 'gray';
let color = this.color && count ? this.color.toLowerCase() : 'gray';
$(`<div class="indicator-pill ellipsis ${color}">${label}</div>`).appendTo(this.action_area);
}
}

View file

@ -237,9 +237,19 @@ class ShortcutDialog extends WidgetDialog {
hidden: 1,
},
{
fieldtype: "Color",
fieldtype: "Select",
fieldname: "color",
label: __("Color"),
options: ["Grey", "Green", "Red", "Orange", "Pink", "Yellow", "Blue", "Cyan"],
default: "Grey",
onchange: () => {
let color = this.dialog.fields_dict.color.value.toLowerCase();
let $select = this.dialog.fields_dict.color.$input;
if (!$select.parent().find('.color-box').get(0)) {
$(`<div class="color-box"></div>`).insertBefore($select.get(0));
}
$select.parent().find('.color-box').get(0).style.backgroundColor = `var(--text-on-${color})`;
}
},
{
fieldtype: "Column Break",

View file

@ -24,6 +24,17 @@
--blue-100: #D3E9FC;
--blue-50 : #F0F8FE;
--cyan-900: #006464;
--cyan-800: #007272;
--cyan-700: #008b8b;
--cyan-600: #02c5c5;
--cyan-500: #00ffff;
--cyan-400: #2ef8f8;
--cyan-300: #6efcfc;
--cyan-200: #a0f8f8;
--cyan-100: #c7fcfc;
--cyan-50 : #dafafa;
--green-900: #2D401D;
--green-800: #44622A;
--green-700: #518B21;
@ -151,6 +162,8 @@
--bg-gray: var(--gray-200);
--bg-light-gray: var(--gray-100);
--bg-purple: var(--purple-100);
--bg-pink: var(--pink-50);
--bg-cyan: var(--cyan-50);
--text-on-blue: var(--blue-600);
--text-on-light-blue: var(--blue-500);
@ -163,6 +176,8 @@
--text-on-gray: var(--gray-600);
--text-on-light-gray: var(--gray-800);
--text-on-purple: var(--purple-500);
--text-on-pink: var(--pink-500);
--text-on-cyan: var(--cyan-600);
--awesomplete-hover-bg: var(--control-bg);

View file

@ -76,6 +76,22 @@ input[type="checkbox"] {
@include card();
}
.frappe-control[data-fieldtype="Select"].frappe-control[data-fieldname="color"] {
select {
padding-left: 40px;
}
.color-box {
position: absolute;
top: calc(50% - 11px);
left: 8px;
width: 22px;
height: 22px;
border-radius: 5px;
z-index: 1;
}
}
.frappe-control[data-fieldtype="Select"] .control-input,
.frappe-control[data-fieldtype="Select"].form-group {
position: relative;

View file

@ -77,6 +77,16 @@
@include indicator-pill-color('green');
}
.indicator.cyan {
@include indicator-color('cyan');
}
.indicator-pill.cyan,
.indicator-pill-right.cyan,
.indicator-pill-round.cyan {
@include indicator-pill-color('cyan');
}
.indicator.blue {
@include indicator-color('blue');
}
@ -131,6 +141,16 @@
@include indicator-pill-color('red');
}
.indicator.pink {
@include indicator-color('pink');
}
.indicator-pill.pink,
.indicator-pill-right.pink,
.indicator-pill-round.pink {
@include indicator-pill-color('pink');
}
.indicator-pill.darkgrey,
.indicator-pill-right.darkgrey,
.indicator-pill-round.darkgrey {

View file

@ -2,25 +2,50 @@ h5.modal-title {
margin: 0px !important;
}
body.modal-open {
overflow: auto;
height: auto;
min-height: 100%;
// Hack to fix incorrect padding applied by Bootstrap
body.modal-open[style^="padding-right"] {
padding-right: 12px !important;
header.navbar {
padding-right: 12px !important;
margin-right: -12px !important;
}
}
.modal {
// Same scrollbar as body
scrollbar-width: auto;
&::-webkit-scrollbar {
width: 12px;
height: 12px;
}
// Hide scrollbar on touch devices
@media(max-width: 991px) {
scrollbar-width: none;
&::-webkit-scrollbar {
width: 0;
height: 0;
}
}
.modal-content {
border-color: var(--border-color);
}
.modal-header {
position: sticky;
top: 0;
z-index: 3;
background: inherit;
padding: var(--padding-md) var(--padding-lg);
padding-bottom: 0;
border-bottom: 0;
// padding-bottom: 0;
border-bottom: 1px solid var(--border-color);
.modal-title {
font-weight: 500;
line-height: 2em;
font-size: $font-size-lg;
max-width: calc(100% - 80px);
}
.modal-actions {
@ -60,9 +85,17 @@ body.modal-open {
}
}
.awesomplete ul {
z-index: 2;
}
.modal-footer {
position: sticky;
bottom: 0;
z-index: 1;
background: inherit;
padding: var(--padding-md) var(--padding-lg);
border-top: 0;
border-top: 1px solid var(--border-color);
justify-content: space-between;
button {

View file

@ -105,6 +105,7 @@
padding: 10px 12px;
height: initial;
line-height: initial;
cursor: pointer;
&.selected {
background-color: var(--control-bg);
@ -163,7 +164,7 @@
}
.ql-editor td {
border: 1px solid var(--border-color);
border: 1px solid var(--dark-border-color);
}
.ql-editor blockquote {
@ -196,5 +197,8 @@
}
.mention[data-is-group="true"] {
background-color: var(--group-mention-bg-color);
.icon {
margin-top: -2px;
margin-left: 4px;
}
}

View file

@ -442,6 +442,11 @@ kbd {
/*rtl styles*/
.frappe-rtl {
text-align: right;
.modal-actions {
right: auto !important;
left: 5px;
}
input, textarea {
direction: rtl !important;
}

View file

@ -161,7 +161,8 @@
.summary-item {
// SIZE & SPACING
margin: 0px 30px;
width: 180px;
min-width: 180px;
max-width: 300px;
height: 62px;
// LAYOUT

View file

@ -9,11 +9,6 @@ html {
}
/* Works on Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb-color);
}
@ -23,7 +18,12 @@ html {
background: var(--scrollbar-track-color);
}
body::-webkit-scrollbar {
width: unset;
height: unset;
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
body::-webkit-scrollbar {
width: 12px;
height: 12px;
}

View file

@ -78,7 +78,7 @@ class TestEnergyPointLog(unittest.TestCase):
points_after_closing_todo = get_points('test@example.com')
# test max_points cap
self.assertNotEquals(points_after_closing_todo,
self.assertNotEqual(points_after_closing_todo,
energy_point_of_user + round(todo_point_rule.points * multiplier_value))
self.assertEqual(points_after_closing_todo,

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