diff --git a/.travis.yml b/.travis.yml
index ea584e38c0..1551f17ec5 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -43,6 +43,7 @@ before_script:
- cd ~/frappe-bench
- bench use test_site
- bench reinstall --yes
+ - bench setup-help
- bench scheduler disable
- bench start &
- sleep 10
@@ -51,4 +52,4 @@ script:
- set -e
- bench --verbose run-tests
- sleep 5
- - bench --verbose run-tests --ui-tests
+ - bench --verbose run-ui-tests --app frappe
diff --git a/frappe/__init__.py b/frappe/__init__.py
index bca23c9355..f3be9e4761 100644
--- a/frappe/__init__.py
+++ b/frappe/__init__.py
@@ -14,7 +14,7 @@ import os, sys, importlib, inspect, json
from .exceptions import *
from .utils.jinja import get_jenv, get_template, render_template, get_email_from_template
-__version__ = '8.4.1'
+__version__ = '8.5.0'
__title__ = "Frappe Framework"
local = Local()
@@ -380,7 +380,7 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message
attachments=None, content=None, doctype=None, name=None, reply_to=None,
cc=[], message_id=None, in_reply_to=None, send_after=None, expose_recipients=None,
send_priority=1, communication=None, retry=1, now=None, read_receipt=None, is_notification=False,
- inline_images=None, template=None, args=None):
+ inline_images=None, template=None, args=None, header=False):
"""Send email using user's default **Email Account** or global default **Email Account**.
@@ -405,6 +405,7 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id
:param template: Name of html template from templates/emails folder
:param args: Arguments for rendering the template
+ :param header: Append header in email
"""
text_content = None
@@ -428,7 +429,7 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message
attachments=attachments, reply_to=reply_to, cc=cc, message_id=message_id, in_reply_to=in_reply_to,
send_after=send_after, expose_recipients=expose_recipients, send_priority=send_priority,
communication=communication, now=now, read_receipt=read_receipt, is_notification=is_notification,
- inline_images=inline_images)
+ inline_images=inline_images, header=header)
whitelisted = []
guest_methods = []
@@ -491,6 +492,7 @@ def clear_cache(user=None, doctype=None):
frappe.sessions.clear_cache()
translate.clear_cache()
reset_metadata_version()
+ clear_domainification_cache()
local.cache = {}
local.new_doc_templates = {}
@@ -1370,6 +1372,22 @@ def get_active_domains():
return active_domains
+def get_active_modules():
+ """ get the active modules from Module Def"""
+ active_modules = cache().hget("modules", "active_modules") or None
+ if active_modules is None:
+ domains = get_active_domains()
+ modules = get_all("Module Def", filters={"restrict_to_domain": ("in", domains)})
+ active_modules = [module.name for module in modules]
+ cache().hset("modules", "active_modules", active_modules)
+
+ return active_modules
+
+def clear_domainification_cache():
+ _cache = cache()
+ _cache.delete_key("domains", "active_domains")
+ _cache.delete_key("modules", "active_modules")
+
def get_system_settings(key):
if not local.system_settings.has_key(key):
local.system_settings.update({key: db.get_single_value('System Settings', key)})
diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py
index 9cabd36c75..74da084beb 100644
--- a/frappe/commands/utils.py
+++ b/frappe/commands/utils.py
@@ -323,30 +323,24 @@ def run_tests(context, app=None, module=None, doctype=None, test=(),
@click.command('run-ui-tests')
@click.option('--app', help="App to run tests on, leave blank for all apps")
-@click.option('--ci', is_flag=True, default=False, help="Run in CI environment")
+@click.option('--test', help="File name of the test you want to run")
+@click.option('--profile', is_flag=True, default=False)
@pass_context
-def run_ui_tests(context, app=None, ci=False):
+def run_ui_tests(context, app=None, test=False, profile=False):
"Run UI tests"
- import subprocess
+ import frappe.test_runner
site = get_site(context)
frappe.init(site=site)
+ frappe.connect()
- if app is None:
- app = ",".join(frappe.get_installed_apps())
-
- cmd = [
- './node_modules/.bin/nightwatch',
- '--config', './apps/frappe/frappe/nightwatch.js',
- '--app', app,
- '--site', site
- ]
-
- if ci:
- cmd.extend(['--env', 'ci_server'])
+ ret = frappe.test_runner.run_ui_tests(app=app, test=test, verbose=context.verbose,
+ profile=profile)
+ if len(ret.failures) == 0 and len(ret.errors) == 0:
+ ret = 0
- bench_path = frappe.utils.get_bench_path()
- subprocess.call(cmd, cwd=bench_path)
+ if os.environ.get('CI'):
+ sys.exit(ret)
@click.command('serve')
@click.option('--port', default=8000)
diff --git a/frappe/contacts/doctype/address/address.py b/frappe/contacts/doctype/address/address.py
index 5455e77468..33a01f3192 100644
--- a/frappe/contacts/doctype/address/address.py
+++ b/frappe/contacts/doctype/address/address.py
@@ -135,15 +135,34 @@ def get_list_context(context=None):
}
def get_address_list(doctype, txt, filters, limit_start, limit_page_length = 20, order_by = None):
- from frappe.www.list import get_list
- user = frappe.session.user
- ignore_permissions = False
- if is_website_user():
- if not filters: filters = []
- filters.append(("Address", "owner", "=", user))
- ignore_permissions = True
-
- return get_list(doctype, txt, filters, limit_start, limit_page_length, ignore_permissions=ignore_permissions)
+ from frappe.www.list import get_list
+ user = frappe.session.user
+ ignore_permissions = False
+ if is_website_user():
+ if not filters: filters = []
+ add_name = []
+ contact = frappe.db.sql("""
+ select
+ address.name
+ from
+ `tabDynamic Link` as link
+ join
+ `tabAddress` as address on link.parent = address.name
+ where
+ link.parenttype = 'Address' and
+ link_name in(
+ select
+ link.link_name from `tabContact` as contact
+ join
+ `tabDynamic Link` as link on contact.name = link.parent
+ where
+ contact.user = %s)""",(user))
+ for c in contact:
+ add_name.append(c[0])
+ filters.append(("Address", "name", "in", add_name))
+ ignore_permissions = True
+
+ return get_list(doctype, txt, filters, limit_start, limit_page_length, ignore_permissions=ignore_permissions)
def has_website_permission(doc, ptype, user, verbose=False):
"""Returns true if there is a related lead or contact related to this document"""
@@ -185,12 +204,12 @@ def get_shipping_address(company):
address_as_dict = address[0]
name, address_template = get_address_templates(address_as_dict)
return address_as_dict.get("name"), frappe.render_template(address_template, address_as_dict)
-
+
def get_company_address(company):
ret = frappe._dict()
ret.company_address = get_default_address('Company', company)
ret.company_address_display = get_address_display(ret.company_address)
-
+
return ret
def address_query(doctype, txt, searchfield, start, page_len, filters):
diff --git a/frappe/core/doctype/domain_settings/domain_settings.py b/frappe/core/doctype/domain_settings/domain_settings.py
index 7ef3654567..7ce25d003e 100644
--- a/frappe/core/doctype/domain_settings/domain_settings.py
+++ b/frappe/core/doctype/domain_settings/domain_settings.py
@@ -8,5 +8,4 @@ from frappe.model.document import Document
class DomainSettings(Document):
def on_update(self):
- cache = frappe.cache()
- cache.delete_key("domains", "active_domains")
\ No newline at end of file
+ frappe.clear_domainification_cache()
\ No newline at end of file
diff --git a/frappe/core/doctype/module_def/module_def.json b/frappe/core/doctype/module_def/module_def.json
index 1a94f7f391..4ff7a40877 100644
--- a/frappe/core/doctype/module_def/module_def.json
+++ b/frappe/core/doctype/module_def/module_def.json
@@ -71,6 +71,37 @@
"search_index": 0,
"set_only_once": 0,
"unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "restrict_to_domain",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Restrict To Domain",
+ "length": 0,
+ "no_copy": 0,
+ "options": "Domain",
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
}
],
"has_web_view": 0,
@@ -84,7 +115,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
- "modified": "2017-06-20 14:35:17.407968",
+ "modified": "2017-07-13 03:05:28.213656",
"modified_by": "Administrator",
"module": "Core",
"name": "Module Def",
diff --git a/frappe/core/doctype/test_runner/test_runner.js b/frappe/core/doctype/test_runner/test_runner.js
index 477d8903de..0c305e7014 100644
--- a/frappe/core/doctype/test_runner/test_runner.js
+++ b/frappe/core/doctype/test_runner/test_runner.js
@@ -11,7 +11,7 @@ frappe.ui.form.on('Test Runner', {
// all tests
frappe.call({
- method: 'frappe.core.doctype.test_runner.test_runner.get_all_tests'
+ method: 'frappe.core.doctype.test_runner.test_runner.get_test_js'
}).always((data) => {
$("
").appendTo(wrapper.empty());
frm.events.run_tests(frm, data.message);
@@ -50,12 +50,30 @@ frappe.ui.form.on('Test Runner', {
"Runtime": details.runtime
};
+ details.assertions.map(a => {
+ // eslint-disable-next-line
+ console.log(`${a.result ? '✔' : '✗'} ${a.message}`);
+ });
+
// eslint-disable-next-line
console.log(JSON.stringify(result, null, 2));
});
QUnit.load();
- QUnit.done(() => {
+
+ QUnit.done(({ total, failed, passed, runtime }) => {
+ // flag for selenium that test is done
+
+ console.log( `Total: ${total}, Failed: ${failed}, Passed: ${passed}, Runtime: ${runtime}` ); // eslint-disable-line
+
+ if(failed) {
+ console.log('Tests Failed'); // eslint-disable-line
+ } else {
+ console.log('Tests Passed'); // eslint-disable-line
+ }
frappe.set_route('Form', 'Test Runner', 'Test Runner');
+
+ $('').appendTo($('body'));
+
});
});
diff --git a/frappe/core/doctype/test_runner/test_runner.json b/frappe/core/doctype/test_runner/test_runner.json
index 0094d6c659..8396d5df43 100644
--- a/frappe/core/doctype/test_runner/test_runner.json
+++ b/frappe/core/doctype/test_runner/test_runner.json
@@ -83,7 +83,7 @@
"issingle": 1,
"istable": 0,
"max_attachments": 0,
- "modified": "2017-06-26 10:57:19.976624",
+ "modified": "2017-07-12 23:16:15.910891",
"modified_by": "Administrator",
"module": "Core",
"name": "Test Runner",
@@ -104,7 +104,7 @@
"print": 1,
"read": 1,
"report": 0,
- "role": "System Manager",
+ "role": "Administrator",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
diff --git a/frappe/core/doctype/test_runner/test_runner.py b/frappe/core/doctype/test_runner/test_runner.py
index 2d66622955..a59ddc69a5 100644
--- a/frappe/core/doctype/test_runner/test_runner.py
+++ b/frappe/core/doctype/test_runner/test_runner.py
@@ -10,18 +10,40 @@ class TestRunner(Document):
pass
@frappe.whitelist()
-def get_all_tests():
- tests = []
- for app in frappe.get_installed_apps():
- tests_path = frappe.get_app_path(app, 'tests', 'ui')
- if os.path.exists(tests_path):
- for basepath, folders, files in os.walk(tests_path): # pylint: disable=unused-variable
- for fname in files:
- if fname.startswith('test') and fname.endswith('.js'):
- path = os.path.join(basepath, fname)
- with open(path, 'r') as fileobj:
- tests.append(dict(
- path = os.path.relpath(frappe.get_app_path(app), path),
- script = fileobj.read()
- ))
- return tests
+def get_test_js():
+ '''Get test + data for app, example: app/tests/ui/test_name.js'''
+ test_path = frappe.db.get_single_value('Test Runner', 'module_path')
+
+ # split
+ app, test_path = test_path.split(os.path.sep, 1)
+ test_js = get_test_data(app)
+
+ # full path
+ test_path = frappe.get_app_path(app, test_path)
+
+ with open(test_path, 'r') as fileobj:
+ test_js.append(dict(
+ script = fileobj.read()
+ ))
+ return test_js
+
+def get_test_data(app):
+ '''Get the test fixtures from all js files in app/tests/ui/data'''
+ test_js = []
+
+ def add_file(path):
+ with open(path, 'r') as fileobj:
+ test_js.append(dict(
+ script = fileobj.read()
+ ))
+
+ data_path = frappe.get_app_path(app, 'tests', 'ui', 'data')
+ if os.path.exists(data_path):
+ for fname in os.listdir(data_path):
+ if fname.endswith('.js'):
+ add_file(os.path.join(data_path, fname))
+
+ if app != 'frappe':
+ add_file(frappe.get_app_path('frappe', 'tests', 'ui', 'data', 'test_lib.js'))
+
+ return test_js
diff --git a/frappe/core/doctype/user/user.json b/frappe/core/doctype/user/user.json
index 3eda403272..0796ff76fb 100644
--- a/frappe/core/doctype/user/user.json
+++ b/frappe/core/doctype/user/user.json
@@ -1949,7 +1949,7 @@
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
- "read_only": 0,
+ "read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
@@ -1971,7 +1971,7 @@
"istable": 0,
"max_attachments": 5,
"menu_index": 0,
- "modified": "2017-05-19 09:12:35.697915",
+ "modified": "2017-07-12 19:24:00.824902",
"modified_by": "Administrator",
"module": "Core",
"name": "User",
diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py
index c91c876680..487cb3fb11 100644
--- a/frappe/core/doctype/user/user.py
+++ b/frappe/core/doctype/user/user.py
@@ -225,11 +225,11 @@ class User(Document):
def password_reset_mail(self, link):
self.send_login_mail(_("Password Reset"),
- "templates/emails/password_reset.html", {"link": link}, now=True)
+ "password_reset", {"link": link}, now=True)
def password_update_mail(self, password):
self.send_login_mail(_("Password Update"),
- "templates/emails/password_update.html", {"new_password": password}, now=True)
+ "password_update", {"new_password": password}, now=True)
def send_welcome_mail_to_user(self):
from frappe.utils import get_url
@@ -248,7 +248,7 @@ class User(Document):
else:
subject = _("Complete Registration")
- self.send_login_mail(subject, "templates/emails/new_user.html",
+ self.send_login_mail(subject, "new_user",
dict(
link=link,
site_url=get_url(),
@@ -279,7 +279,7 @@ class User(Document):
sender = frappe.session.user not in STANDARD_USERS and get_formatted_email(frappe.session.user) or None
frappe.sendmail(recipients=self.email, sender=sender, subject=subject,
- message=frappe.get_template(template).render(args),
+ template=template, args=args,
delayed=(not now) if now!=None else self.flags.delay_emails, retry=3)
def a_system_manager_should_exist(self):
@@ -547,7 +547,7 @@ def update_password(new_password, key=None, old_password=None):
def test_password_strength(new_password, key=None, old_password=None, user_data=[]):
from frappe.utils.password_strength import test_password_strength as _test_password_strength
- password_policy = frappe.db.get_value("System Settings", None,
+ password_policy = frappe.db.get_value("System Settings", None,
["enable_password_policy", "minimum_password_score"], as_dict=True) or {}
enable_password_policy = cint(password_policy.get("enable_password_policy", 0))
@@ -557,7 +557,7 @@ def test_password_strength(new_password, key=None, old_password=None, user_data=
return {}
if not user_data:
- user_data = frappe.db.get_value('User', frappe.session.user,
+ user_data = frappe.db.get_value('User', frappe.session.user,
['first_name', 'middle_name', 'last_name', 'email', 'birth_date'])
if new_password:
diff --git a/frappe/desk/calendar.py b/frappe/desk/calendar.py
index fa01b3f8de..d9cd03004a 100644
--- a/frappe/desk/calendar.py
+++ b/frappe/desk/calendar.py
@@ -19,16 +19,8 @@ def update_event(args, field_map):
def get_event_conditions(doctype, filters=None):
"""Returns SQL conditions with user permissions and filters for event queries"""
- from frappe.desk.reportview import build_match_conditions
+ from frappe.desk.reportview import get_filters_cond
if not frappe.has_permission(doctype):
frappe.throw(_("Not Permitted"), frappe.PermissionError)
- conditions = build_match_conditions(doctype)
- conditions = conditions and (" and " + conditions) or ""
- if filters:
- filters = json.loads(filters)
- for key in filters:
- if filters[key]:
- conditions += 'and `{0}` = "{1}"'.format(frappe.db.escape(key), frappe.db.escape(filters[key]))
-
- return conditions
+ return get_filters_cond(doctype, filters, [], with_match_conditions = True)
diff --git a/frappe/desk/doctype/event/test_records.json b/frappe/desk/doctype/event/test_records.json
index aaadc881b8..41d5803083 100644
--- a/frappe/desk/doctype/event/test_records.json
+++ b/frappe/desk/doctype/event/test_records.json
@@ -3,18 +3,21 @@
"doctype": "Event",
"subject":"_Test Event 1",
"starts_on": "2014-01-01",
- "event_type": "Public"
+ "event_type": "Public",
+ "creation": "2014-01-01"
},
{
"doctype": "Event",
- "starts_on": "2014-01-01",
"subject":"_Test Event 2",
- "event_type": "Private"
+ "starts_on": "2014-01-01",
+ "event_type": "Private",
+ "creation": "2014-01-01"
},
{
"doctype": "Event",
- "starts_on": "2014-01-01",
"subject": "_Test Event 3",
- "event_type": "Private"
+ "starts_on": "2014-02-01",
+ "event_type": "Private",
+ "creation": "2014-02-01"
}
]
diff --git a/frappe/desk/doctype/todo/todo.json b/frappe/desk/doctype/todo/todo.json
index ebd2489e40..487dcbd3d8 100644
--- a/frappe/desk/doctype/todo/todo.json
+++ b/frappe/desk/doctype/todo/todo.json
@@ -514,7 +514,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
- "modified": "2017-07-06 10:23:39.656033",
+ "modified": "2017-07-13 17:44:54.369254",
"modified_by": "Administrator",
"module": "Desk",
"name": "ToDo",
diff --git a/frappe/desk/notifications.py b/frappe/desk/notifications.py
index 3924afd7a4..d0ee87a209 100644
--- a/frappe/desk/notifications.py
+++ b/frappe/desk/notifications.py
@@ -13,10 +13,12 @@ def get_notifications():
return
config = get_notification_config()
+
groups = config.get("for_doctype").keys() + config.get("for_module").keys()
cache = frappe.cache()
notification_count = {}
+ notification_percent = {}
for name in groups:
count = cache.hget("notification_count:" + name, frappe.session.user)
@@ -27,6 +29,7 @@ def get_notifications():
"open_count_doctype": get_notifications_for_doctypes(config, notification_count),
"open_count_module": get_notifications_for_modules(config, notification_count),
"open_count_other": get_notifications_for_other(config, notification_count),
+ "targets": get_notifications_for_targets(config, notification_percent),
"new_messages": get_new_messages()
}
@@ -111,6 +114,49 @@ def get_notifications_for_doctypes(config, notification_count):
return open_count_doctype
+def get_notifications_for_targets(config, notification_percent):
+ """Notifications for doc targets"""
+ can_read = frappe.get_user().get_can_read()
+ doc_target_percents = {}
+
+ # doc_target_percents = {
+ # "Company": {
+ # "Acme": 87,
+ # "RobotsRUs": 50,
+ # }, {}...
+ # }
+
+ for doctype in config.targets:
+ if doctype in can_read:
+ if doctype in notification_percent:
+ doc_target_percents[doctype] = notification_percent[doctype]
+ else:
+ doc_target_percents[doctype] = {}
+ d = config.targets[doctype]
+ condition = d["filters"]
+ target_field = d["target_field"]
+ value_field = d["value_field"]
+ try:
+ if isinstance(condition, dict):
+ doc_list = frappe.get_list(doctype, fields=["name", target_field, value_field],
+ filters=condition, limit_page_length = 100, ignore_ifnull=True)
+
+ except frappe.PermissionError:
+ frappe.clear_messages()
+ pass
+ except Exception as e:
+ if e.args[0]!=1412:
+ raise
+
+ else:
+ for doc in doc_list:
+ value = doc[value_field]
+ target = doc[target_field]
+ doc_target_percents[doctype][doc.name] = (value/target * 100) if value < target else 100
+
+ return doc_target_percents
+
+
def clear_notifications(user=None):
if frappe.flags.in_install:
return
@@ -163,7 +209,7 @@ def get_notification_config():
config = frappe._dict()
for notification_config in frappe.get_hooks().notification_config:
nc = frappe.get_attr(notification_config)()
- for key in ("for_doctype", "for_module", "for_other"):
+ for key in ("for_doctype", "for_module", "for_other", "targets"):
config.setdefault(key, {})
config[key].update(nc.get(key, {}))
return config
diff --git a/frappe/desk/query_report.py b/frappe/desk/query_report.py
index 3b04ad6741..8140a0b11e 100644
--- a/frappe/desk/query_report.py
+++ b/frappe/desk/query_report.py
@@ -208,7 +208,7 @@ def add_total_row(result, columns, meta = None):
total_row[i] = result[0][i]
for i in has_percent:
- total_row[i] = total_row[i] / len(result)
+ total_row[i] = flt(total_row[i]) / len(result)
first_col_fieldtype = None
if isinstance(columns[0], basestring):
diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py
index 920fb7f36b..26c81bdbeb 100644
--- a/frappe/desk/reportview.py
+++ b/frappe/desk/reportview.py
@@ -334,7 +334,7 @@ def build_match_conditions(doctype, as_condition=True):
else:
return match_conditions
-def get_filters_cond(doctype, filters, conditions, ignore_permissions=None):
+def get_filters_cond(doctype, filters, conditions, ignore_permissions=None, with_match_conditions=False):
if filters:
flt = filters
if isinstance(filters, dict):
@@ -350,6 +350,10 @@ def get_filters_cond(doctype, filters, conditions, ignore_permissions=None):
query = DatabaseQuery(doctype)
query.filters = flt
query.conditions = conditions
+
+ if with_match_conditions:
+ query.build_match_conditions()
+
query.build_filter_conditions(flt, conditions, ignore_permissions)
cond = ' and ' + ' and '.join(query.conditions)
diff --git a/frappe/docs/assets/img/desk/__init__.py b/frappe/docs/assets/img/desk/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/docs/assets/img/desk/bar_graph.png b/frappe/docs/assets/img/desk/bar_graph.png
new file mode 100644
index 0000000000..d25254af6d
Binary files /dev/null and b/frappe/docs/assets/img/desk/bar_graph.png differ
diff --git a/frappe/docs/assets/img/desk/line_graph.png b/frappe/docs/assets/img/desk/line_graph.png
new file mode 100644
index 0000000000..02c60c7c18
Binary files /dev/null and b/frappe/docs/assets/img/desk/line_graph.png differ
diff --git a/frappe/docs/user/en/guides/automated-testing/qunit-testing.md b/frappe/docs/user/en/guides/automated-testing/qunit-testing.md
index a8e35607d5..d66dde2782 100644
--- a/frappe/docs/user/en/guides/automated-testing/qunit-testing.md
+++ b/frappe/docs/user/en/guides/automated-testing/qunit-testing.md
@@ -14,32 +14,56 @@ In the CI, all QUnit tests are run by the **Test Runner** using `frappe/tests/te
+### Running Tests
+
+To run a Test Runner based test, use the `run-ui-tests` bench command by passing the name of the file you want to run.
+
+ bench run-ui-tests --test frappe/tests/ui/test_list.js
+
+This will pass the filename to `test_test_runner.py` that will load the required JS in the browser and execute the tests
+
+### Adding Fixtures / Test Data
+
+You can also add data that you require for all tests in the `tests/ui/data` folder of your app. All the files in this folder will be loaded in the browser before running the test.
+
+The file `frappe/tests/ui/data/test_lib.js`, which contains library functions for testing is always loaded.
+
+### Running All UI Tests
+
+To run all UI tests together for your app run
+
+ bench run-ui-tests --app [app_name]
+
+This will run all the files in your `tests/ui` folder one by one.
+
### Example QUnit Test
Here is the example of the To Do test in QUnit
- QUnit.test("test quick entry", function(assert) {
- assert.expect(2);
- let done = assert.async();
- let random = frappe.utils.get_random(10);
-
- frappe.set_route('List', 'ToDo')
- .then(() => {
- return frappe.new_doc('ToDo');
- })
- .then(() => {
- frappe.quick_entry.dialog.set_value('description', random);
- return frappe.quick_entry.insert();
- })
- .then((doc) => {
- assert.ok(doc && !doc.__islocal);
- return frappe.set_route('Form', 'ToDo', doc.name);
- })
- .then(() => {
- assert.ok(cur_frm.doc.description.includes(random));
- done();
- });
- });
+ QUnit.test("Test quick entry", function(assert) {
+ assert.expect(2);
+ let done = assert.async();
+ let random_text = frappe.utils.get_random(10);
+
+ frappe.run_serially([
+ () => frappe.set_route('List', 'ToDo'),
+ () => frappe.new_doc('ToDo'),
+ () => frappe.quick_entry.dialog.set_value('description', random_text),
+ () => frappe.quick_entry.insert(),
+ (doc) => {
+ assert.ok(doc && !doc.__islocal);
+ return frappe.set_route('Form', 'ToDo', doc.name);
+ },
+ () => assert.ok(cur_frm.doc.description.includes(random_text)),
+
+ // Delete the created ToDo
+ () => frappe.tests.click_page_head_item('Menu'),
+ () => frappe.tests.click_dropdown_item('Delete'),
+ () => frappe.tests.click_page_head_item('Yes'),
+
+ () => done()
+ ]);
+ });
### Writing Test Friendly Code with Promises
diff --git a/frappe/docs/user/en/guides/desk/making_graphs.md b/frappe/docs/user/en/guides/desk/making_graphs.md
new file mode 100644
index 0000000000..9234fa58b4
--- /dev/null
+++ b/frappe/docs/user/en/guides/desk/making_graphs.md
@@ -0,0 +1,61 @@
+# Making Graphs
+
+The Frappe UI **Graph** object enables you to render simple line and bar graphs for a discreet set of data points. You can also set special checkpoint values and summary stats.
+
+### Example: Line graph
+Here's is an example of a simple sales graph:
+
+ render_graph: function() {
+ $('.form-graph').empty();
+
+ var months = ['Aug', 'Sep', 'Oct', 'Nov', 'Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul'];
+ var values = [2410, 3100, 1700, 1200, 2700, 1600, 2740, 1000, 850, 1500, 400, 2013];
+
+ var goal = 2500;
+ var current_val = 2013;
+
+ new frappe.ui.Graph({
+ parent: $('.form-graph'),
+ width: 700,
+ height: 140,
+ mode: 'line-graph',
+
+ title: 'Sales',
+ subtitle: 'Monthly',
+ y_values: values,
+ x_points: months,
+
+ specific_values: [
+ {
+ name: "Goal",
+ line_type: "dashed", // "dashed" or "solid"
+ value: goal
+ },
+ ],
+ summary_values: [
+ {
+ name: "This month",
+ color: 'green', // Indicator colors: 'grey', 'blue', 'red',
+ // 'green', 'orange', 'purple', 'darkgrey',
+ // 'black', 'yellow', 'lightblue'
+ value: '₹ ' + current_val
+ },
+ {
+ name: "Goal",
+ color: 'blue',
+ value: '₹ ' + goal
+ },
+ {
+ name: "Completed",
+ color: 'green',
+ value: (current_val/goal*100).toFixed(1) + "%"
+ }
+ ]
+ });
+ },
+
+
+
+Setting the mode to 'bar-graph':
+
+
diff --git a/frappe/email/doctype/email_queue/email_queue.json b/frappe/email/doctype/email_queue/email_queue.json
index ff09b44f36..4445f60a02 100644
--- a/frappe/email/doctype/email_queue/email_queue.json
+++ b/frappe/email/doctype/email_queue/email_queue.json
@@ -1,5 +1,6 @@
{
"allow_copy": 0,
+ "allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"autoname": "hash",
@@ -14,6 +15,7 @@
"engine": "InnoDB",
"fields": [
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -43,6 +45,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -72,6 +75,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -101,6 +105,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -129,6 +134,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -159,6 +165,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -187,6 +194,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -216,6 +224,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -245,6 +254,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -273,6 +283,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -303,6 +314,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -332,6 +344,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -362,6 +375,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -392,6 +406,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -421,6 +436,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -450,6 +466,7 @@
"unique": 0
},
{
+ "allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
@@ -477,20 +494,50 @@
"search_index": 0,
"set_only_once": 0,
"unique": 0
+ },
+ {
+ "allow_bulk_edit": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "collapsible": 0,
+ "columns": 0,
+ "fieldname": "attachments",
+ "fieldtype": "Code",
+ "hidden": 1,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_standard_filter": 0,
+ "label": "Attachments",
+ "length": 0,
+ "no_copy": 0,
+ "permlevel": 0,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "read_only": 0,
+ "remember_last_selected_value": 0,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "set_only_once": 0,
+ "unique": 0
}
],
+ "has_web_view": 0,
"hide_heading": 0,
"hide_toolbar": 0,
"icon": "fa fa-envelope",
"idx": 1,
"image_view": 0,
"in_create": 1,
- "in_dialog": 0,
"is_submittable": 0,
"issingle": 0,
"istable": 0,
"max_attachments": 0,
- "modified": "2017-02-24 17:42:10.878546",
+ "modified": "2017-07-07 16:29:15.780393",
"modified_by": "Administrator",
"module": "Email",
"name": "Email Queue",
diff --git a/frappe/email/doctype/newsletter/newsletter.py b/frappe/email/doctype/newsletter/newsletter.py
index a54ab28c8d..29ecbee853 100755
--- a/frappe/email/doctype/newsletter/newsletter.py
+++ b/frappe/email/doctype/newsletter/newsletter.py
@@ -70,8 +70,9 @@ class Newsletter(Document):
for file in files:
try:
- file = get_file(file.name)
- attachments.append({"fname": file[0], "fcontent": file[1]})
+ # these attachments will be attached on-demand
+ # and won't be stored in the message
+ attachments.append({"fid": file.name})
except IOError:
frappe.throw(_("Unable to find attachment {0}").format(a))
diff --git a/frappe/email/email_body.py b/frappe/email/email_body.py
index 290735361d..41753dbaf7 100755
--- a/frappe/email/email_body.py
+++ b/frappe/email/email_body.py
@@ -2,7 +2,7 @@
# MIT License. See license.txt
from __future__ import unicode_literals
-import frappe, re
+import frappe, re, os
from frappe.utils.pdf import get_pdf
from frappe.email.smtp import get_outgoing_email_account
from frappe.utils import (get_url, scrub_urls, strip, expand_relative_urls, cint,
@@ -15,7 +15,7 @@ from email.mime.multipart import MIMEMultipart
def get_email(recipients, sender='', msg='', subject='[No Subject]',
text_content = None, footer=None, print_html=None, formatted=None, attachments=None,
content=None, reply_to=None, cc=[], email_account=None, expose_recipients=None,
- inline_images=[]):
+ inline_images=[], header=False):
""" Prepare an email with the following format:
- multipart/mixed
- multipart/alternative
@@ -31,13 +31,15 @@ def get_email(recipients, sender='', msg='', subject='[No Subject]',
if not content.strip().startswith("<"):
content = markdown(content)
- emailobj.set_html(content, text_content, footer=footer,
+ emailobj.set_html(content, text_content, footer=footer, header=header,
print_html=print_html, formatted=formatted, inline_images=inline_images)
if isinstance(attachments, dict):
attachments = [attachments]
for attach in (attachments or []):
+ # cannot attach if no filecontent
+ if attach.get('fcontent') is None: continue
emailobj.add_attachment(**attach)
return emailobj
@@ -74,10 +76,11 @@ class EMail:
self.email_account = email_account or get_outgoing_email_account()
def set_html(self, message, text_content = None, footer=None, print_html=None,
- formatted=None, inline_images=None):
+ formatted=None, inline_images=None, header=False):
"""Attach message in the html portion of multipart/alternative"""
if not formatted:
- formatted = get_formatted_html(self.subject, message, footer, print_html, email_account=self.email_account)
+ formatted = get_formatted_html(self.subject, message, footer, print_html,
+ email_account=self.email_account, header=header)
# this is the first html part of a multi-part message,
# convert to text well
@@ -100,21 +103,12 @@ class EMail:
def set_part_html(self, message, inline_images):
from email.mime.text import MIMEText
- if inline_images:
- # process inline images
- _inline_images = []
- for image in inline_images:
- # images in dict like {filename:'', filecontent:'raw'}
- content_id = random_string(10)
- message = replace_filename_with_cid(message,
- image.get('filename'), content_id)
+ has_inline_images = re.search('''embed=['"].*?['"]''', message)
- _inline_images.append({
- 'filename': image.get('filename'),
- 'filecontent': image.get('filecontent'),
- 'content_id': content_id
- })
+ if has_inline_images:
+ # process inline images
+ message, _inline_images = replace_filename_with_cid(message)
# prepare parts
msg_related = MIMEMultipart('related')
@@ -158,48 +152,11 @@ class EMail:
def add_attachment(self, fname, fcontent, content_type=None,
parent=None, content_id=None, inline=False):
"""add attachment"""
- from email.mime.audio import MIMEAudio
- from email.mime.base import MIMEBase
- from email.mime.image import MIMEImage
- from email.mime.text import MIMEText
-
- import mimetypes
- if not content_type:
- content_type, encoding = mimetypes.guess_type(fname)
-
- if content_type is None:
- # No guess could be made, or the file is encoded (compressed), so
- # use a generic bag-of-bits type.
- content_type = 'application/octet-stream'
-
- maintype, subtype = content_type.split('/', 1)
- if maintype == 'text':
- # Note: we should handle calculating the charset
- if isinstance(fcontent, unicode):
- fcontent = fcontent.encode("utf-8")
- part = MIMEText(fcontent, _subtype=subtype, _charset="utf-8")
- elif maintype == 'image':
- part = MIMEImage(fcontent, _subtype=subtype)
- elif maintype == 'audio':
- part = MIMEAudio(fcontent, _subtype=subtype)
- else:
- part = MIMEBase(maintype, subtype)
- part.set_payload(fcontent)
- # Encode the payload using Base64
- from email import encoders
- encoders.encode_base64(part)
-
- # Set the filename parameter
- if fname:
- attachment_type = 'inline' if inline else 'attachment'
- part.add_header(b'Content-Disposition', attachment_type, filename=fname.encode('utf=8'))
- if content_id:
- part.add_header(b'Content-ID', '<{0}>'.format(content_id))
if not parent:
parent = self.msg_root
- parent.attach(part)
+ add_attachment(fname, fcontent, content_type, parent, content_id, inline)
def add_pdf_attachment(self, name, html, options=None):
self.add_attachment(name, get_pdf(html, options), 'application/octet-stream')
@@ -276,11 +233,12 @@ class EMail:
self.make()
return self.msg_root.as_string()
-def get_formatted_html(subject, message, footer=None, print_html=None, email_account=None):
+def get_formatted_html(subject, message, footer=None, print_html=None, email_account=None, header=False):
if not email_account:
email_account = get_outgoing_email_account(False)
rendered_email = frappe.get_template("templates/emails/standard.html").render({
+ "header": get_header() if header else None,
"content": message,
"signature": get_signature(email_account),
"footer": get_footer(email_account, footer),
@@ -291,6 +249,52 @@ def get_formatted_html(subject, message, footer=None, print_html=None, email_acc
return scrub_urls(rendered_email)
+def add_attachment(fname, fcontent, content_type=None,
+ parent=None, content_id=None, inline=False):
+ """Add attachment to parent which must an email object"""
+ from email.mime.audio import MIMEAudio
+ from email.mime.base import MIMEBase
+ from email.mime.image import MIMEImage
+ from email.mime.text import MIMEText
+
+ import mimetypes
+ if not content_type:
+ content_type, encoding = mimetypes.guess_type(fname)
+
+ if not parent:
+ return
+
+ if content_type is None:
+ # No guess could be made, or the file is encoded (compressed), so
+ # use a generic bag-of-bits type.
+ content_type = 'application/octet-stream'
+
+ maintype, subtype = content_type.split('/', 1)
+ if maintype == 'text':
+ # Note: we should handle calculating the charset
+ if isinstance(fcontent, unicode):
+ fcontent = fcontent.encode("utf-8")
+ part = MIMEText(fcontent, _subtype=subtype, _charset="utf-8")
+ elif maintype == 'image':
+ part = MIMEImage(fcontent, _subtype=subtype)
+ elif maintype == 'audio':
+ part = MIMEAudio(fcontent, _subtype=subtype)
+ else:
+ part = MIMEBase(maintype, subtype)
+ part.set_payload(fcontent)
+ # Encode the payload using Base64
+ from email import encoders
+ encoders.encode_base64(part)
+
+ # Set the filename parameter
+ if fname:
+ attachment_type = 'inline' if inline else 'attachment'
+ part.add_header(b'Content-Disposition', attachment_type, filename=fname.encode('utf=8'))
+ if content_id:
+ part.add_header(b'Content-ID', '<{0}>'.format(content_id))
+
+ parent.attach(part)
+
def get_message_id():
'''Returns Message ID created from doctype and name'''
return "<{unique}@{site}>".format(
@@ -329,11 +333,86 @@ def get_footer(email_account, footer=None):
return footer
-def replace_filename_with_cid(message, filename, content_id):
- """ Replaces
with
-
+def replace_filename_with_cid(message):
+ """ Replaces
with
+
and return the modified message and
+ a list of inline_images with {filename, filecontent, content_id}
"""
- message = re.sub('''embed=['"]{0}['"]'''.format(filename),
+
+ inline_images = []
+
+ while True:
+ matches = re.search('''embed=["'](.*?)["']''', message)
+ if not matches: break
+ groups = matches.groups()
+
+ # found match
+ img_path = groups[0]
+ filename = img_path.rsplit('/')[-1]
+
+ filecontent = get_filecontent_from_path(img_path)
+ if not filecontent:
+ message = re.sub('''embed=['"]{0}['"]'''.format(img_path), '', message)
+ continue
+
+ content_id = random_string(10)
+
+ inline_images.append({
+ 'filename': filename,
+ 'filecontent': filecontent,
+ 'content_id': content_id
+ })
+
+ message = re.sub('''embed=['"]{0}['"]'''.format(img_path),
'src="cid:{0}"'.format(content_id), message)
- return message
+ return (message, inline_images)
+
+def get_filecontent_from_path(path):
+ if not path: return
+
+ if path.startswith('/'):
+ path = path[1:]
+
+ if path.startswith('assets/'):
+ # from public folder
+ full_path = os.path.abspath(path)
+ elif path.startswith('files/'):
+ # public file
+ full_path = frappe.get_site_path('public', path)
+ elif path.startswith('private/files/'):
+ # private file
+ full_path = frappe.get_site_path(path)
+ else:
+ full_path = path
+
+ if os.path.exists(full_path):
+ with open(full_path) as f:
+ filecontent = f.read()
+
+ return filecontent
+ else:
+ print(full_path + ' doesn\'t exists')
+ return None
+
+
+def get_header():
+ """ Build header from template """
+ from frappe.utils.jinja import get_email_from_template
+
+ default_brand_image = 'assets/frappe/images/favicon.png' # svg doesn't work in email
+ email_brand_image = frappe.get_hooks('email_brand_image')
+ if len(email_brand_image):
+ email_brand_image = email_brand_image[-1]
+ else:
+ email_brand_image = default_brand_image
+
+ email_brand_image = default_brand_image
+ brand_text = frappe.get_hooks('app_title')[-1]
+
+ email_header, text = get_email_from_template('email_header', {
+ 'brand_image': email_brand_image,
+ 'brand_text': brand_text
+ })
+
+ return email_header
diff --git a/frappe/email/queue.py b/frappe/email/queue.py
index 9abb9dccd2..dbbec7bd12 100755
--- a/frappe/email/queue.py
+++ b/frappe/email/queue.py
@@ -5,13 +5,14 @@ from __future__ import unicode_literals
from six.moves import range
import frappe
import HTMLParser
-import smtplib, quopri
+import smtplib, quopri, json
from frappe import msgprint, throw, _
from frappe.email.smtp import SMTPServer, get_outgoing_email_account
-from frappe.email.email_body import get_email, get_formatted_html
+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
from frappe.utils import get_url, nowdate, encode, now_datetime, add_days, split_emails, cstr, cint
+from frappe.utils.file_manager import get_file
from rq.timeouts import JobTimeoutException
from frappe.utils.scheduler import log
@@ -21,7 +22,8 @@ def send(recipients=None, sender=None, subject=None, message=None, text_content=
reference_name=None, unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None,
attachments=None, reply_to=None, cc=[], message_id=None, in_reply_to=None, send_after=None,
expose_recipients=None, send_priority=1, communication=None, now=False, read_receipt=None,
- queue_separately=False, is_notification=False, add_unsubscribe_link=1, inline_images=None):
+ queue_separately=False, is_notification=False, add_unsubscribe_link=1, inline_images=None,
+ header=False):
"""Add email to sending queue (Email Queue)
:param recipients: List of recipients.
@@ -44,6 +46,7 @@ def send(recipients=None, sender=None, subject=None, message=None, text_content=
:param is_notification: Marks email as notification so will not trigger notifications from system
:param add_unsubscribe_link: Send unsubscribe link in the footer of the Email, default 1.
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id
+ :param header: Append header in email (boolean)
"""
if not unsubscribe_method:
unsubscribe_method = "/api/method/frappe.email.queue.unsubscribe"
@@ -72,7 +75,7 @@ def send(recipients=None, sender=None, subject=None, message=None, text_content=
except HTMLParser.HTMLParseError:
text_content = "See html attachment"
- formatted = get_formatted_html(subject, message, email_account=email_account)
+ formatted = get_formatted_html(subject, message, email_account=email_account, header=header)
if reference_doctype and reference_name:
unsubscribed = [d.email for d in frappe.db.get_all("Email Unsubscribe", "email",
@@ -116,6 +119,7 @@ def send(recipients=None, sender=None, subject=None, message=None, text_content=
queue_separately=queue_separately,
is_notification = is_notification,
inline_images = inline_images,
+ header=header,
now=now)
@@ -145,6 +149,14 @@ def get_email_queue(recipients, sender, subject, **kwargs):
'''Make Email Queue object'''
e = frappe.new_doc('Email Queue')
e.priority = kwargs.get('send_priority')
+ attachments = kwargs.get('attachments')
+ if attachments:
+ # store attachments with fid, to be attached on-demand later
+ _attachments = []
+ for att in attachments:
+ if att.get('fid'):
+ _attachments.append(att)
+ e.attachments = json.dumps(_attachments)
try:
mail = get_email(recipients,
@@ -157,7 +169,8 @@ def get_email_queue(recipients, sender, subject, **kwargs):
cc=kwargs.get('cc'),
email_account=kwargs.get('email_account'),
expose_recipients=kwargs.get('expose_recipients'),
- inline_images=kwargs.get('inline_images'))
+ inline_images=kwargs.get('inline_images'),
+ header=kwargs.get('header'))
mail.set_message_id(kwargs.get('message_id'),kwargs.get('is_notification'))
if kwargs.get('read_receipt'):
@@ -333,7 +346,7 @@ def send_one(email, smtpserver=None, auto_commit=True, now=False, from_test=Fals
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
+ show_as_cc, add_unsubscribe_link, attachments
from
`tabEmail Queue`
where
@@ -426,6 +439,7 @@ where name=%s""", (unicode(e), email.name), auto_commit=auto_commit)
frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit)
if now:
+ print(frappe.get_traceback())
raise e
else:
@@ -459,7 +473,31 @@ def prepare_message(email, recipient, recipients_list):
message = message.replace("", quopri.encodestring(email_sent_message))
message = message.replace("", recipient)
- return message
+
+ if not email.attachments:
+ return message
+
+ # On-demand attachments
+ from email.parser import Parser
+
+ msg_obj = Parser().parsestr(message)
+ attachments = json.loads(email.attachments)
+
+ for attachment in attachments:
+ if attachment.get('fcontent'): continue
+
+ fid = attachment.get('fid')
+ if not fid: continue
+
+ fname, fcontent = get_file(fid)
+ attachment.update({
+ 'fname': fname,
+ 'fcontent': fcontent,
+ 'parent': msg_obj
+ })
+ add_attachment(**attachment)
+
+ return msg_obj.as_string()
def clear_outbox():
"""Remove low priority older than 31 days in Outbox and expire mails not sent for 7 days.
diff --git a/frappe/email/test_email_body.py b/frappe/email/test_email_body.py
index 1ca56dce5d..fda4646b68 100644
--- a/frappe/email/test_email_body.py
+++ b/frappe/email/test_email_body.py
@@ -2,7 +2,7 @@
# License: GNU General Public License v3. See license.txt
from __future__ import unicode_literals
-import unittest, os, base64
+import frappe, unittest, os, base64
from frappe.email.email_body import replace_filename_with_cid, get_email
class TestEmailBody(unittest.TestCase):
@@ -11,16 +11,15 @@ class TestEmailBody(unittest.TestCase):
Hey John Doe!
This is embedded image you asked for
-
![]()
+
'''
email_text = '''
Hey John Doe!
This is the text version of this email
'''
- frappe_app_path = os.path.join('..', 'apps', 'frappe')
- img_path = os.path.join(frappe_app_path, 'frappe', 'public', 'images', 'favicon.png')
+ img_path = os.path.abspath('assets/frappe/images/favicon.png')
with open(img_path) as f:
img_content = f.read()
img_base64 = base64.b64encode(img_content)
@@ -33,11 +32,7 @@ This is the text version of this email
sender='me@example.com',
subject='Test Subject',
content=email_html,
- text_content=email_text,
- inline_images=[{
- 'filename': 'favicon.png',
- 'filecontent': img_content
- }]
+ text_content=email_text
).as_string()
@@ -86,15 +81,18 @@ w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
def test_replace_filename_with_cid(self):
original_message = '''
'''
+ message, inline_images = replace_filename_with_cid(original_message)
+
processed_message = '''
- '''
- message = replace_filename_with_cid(original_message, 'test.jpg', 'abcdefghij')
+ '''.format(inline_images[0].get('content_id'))
self.assertEquals(message, processed_message)
diff --git a/frappe/oauth.py b/frappe/oauth.py
index bf7a35af3c..645a68e211 100644
--- a/frappe/oauth.py
+++ b/frappe/oauth.py
@@ -1,5 +1,6 @@
from __future__ import print_function
import frappe, urllib
+import pytz
from frappe import _
from urlparse import parse_qs, urlparse
@@ -227,8 +228,10 @@ class OAuthWebRequestValidator(RequestValidator):
def validate_bearer_token(self, token, scopes, request):
# Remember to check expiration and scope membership
- otoken = frappe.get_doc("OAuth Bearer Token", token) #{"access_token": str(token)})
- is_token_valid = (frappe.utils.datetime.datetime.now() < otoken.expiration_time) \
+ otoken = frappe.get_doc("OAuth Bearer Token", token)
+ token_expiration_local = otoken.expiration_time.replace(tzinfo=pytz.timezone(frappe.utils.get_time_zone()))
+ token_expiration_utc = token_expiration_local.astimezone(pytz.utc)
+ is_token_valid = (frappe.utils.datetime.datetime.utcnow().replace(tzinfo=pytz.utc) < token_expiration_utc) \
and otoken.status != "Revoked"
client_scopes = frappe.db.get_value("OAuth Client", otoken.client, 'scopes').split(get_url_delimiter())
are_scopes_valid = True
diff --git a/frappe/public/build.json b/frappe/public/build.json
index 75e4e76469..3b58de727b 100755
--- a/frappe/public/build.json
+++ b/frappe/public/build.json
@@ -161,6 +161,7 @@
"public/js/frappe/query_string.js",
"public/js/frappe/ui/charts.js",
+ "public/js/frappe/ui/graph.js",
"public/js/frappe/misc/rating_icons.html",
"public/js/frappe/feedback.js"
diff --git a/frappe/public/css/desk.css b/frappe/public/css/desk.css
index fa13c421fa..ebe34f0de2 100644
--- a/frappe/public/css/desk.css
+++ b/frappe/public/css/desk.css
@@ -508,6 +508,17 @@ fieldset[disabled] .form-control {
cursor: pointer;
margin-right: 10px;
}
+a.progress-small .progress-chart {
+ width: 60px;
+ margin-top: 4px;
+ float: right;
+}
+a.progress-small .progress {
+ margin-bottom: 0;
+}
+a.progress-small .progress-bar {
+ background-color: #98d85b;
+}
/* on small screens, show only icons on top */
@media (max-width: 767px) {
.module-view-layout .nav-stacked > li {
diff --git a/frappe/public/css/form.css b/frappe/public/css/form.css
index d822b04975..844c2dc761 100644
--- a/frappe/public/css/form.css
+++ b/frappe/public/css/form.css
@@ -642,6 +642,92 @@ select.form-control {
box-shadow: none;
}
}
+/* goals */
+.goals-page-container {
+ background-color: #fafbfc;
+ padding-top: 1px;
+}
+.goals-page-container .goal-container {
+ background-color: #fff;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+ border-radius: 2px;
+ padding: 10px;
+ margin: 10px;
+}
+.graph-container .graphics {
+ margin-top: 10px;
+ padding: 10px 0px;
+}
+.graph-container .stats-group {
+ display: flex;
+ justify-content: space-around;
+ flex: 1;
+}
+.graph-container .stats-container {
+ display: flex;
+ justify-content: space-around;
+}
+.graph-container .stats-container .stats {
+ padding-bottom: 15px;
+}
+.graph-container .stats-container .stats-title {
+ color: #8D99A6;
+}
+.graph-container .stats-container .stats-value {
+ font-size: 20px;
+ font-weight: 300;
+}
+.graph-container .stats-container .stats-description {
+ font-size: 12px;
+ color: #8D99A6;
+}
+.graph-container .stats-container .graph-data .stats-value {
+ color: #98d85b;
+}
+.bar-graph .axis,
+.line-graph .axis {
+ font-size: 10px;
+ fill: #6a737d;
+}
+.bar-graph .axis line,
+.line-graph .axis line {
+ stroke: rgba(27, 31, 35, 0.1);
+}
+.data-points circle {
+ fill: #28a745;
+ stroke: #fff;
+ stroke-width: 2;
+}
+.data-points g.mini {
+ fill: #98d85b;
+}
+.data-points path {
+ fill: none;
+ stroke: #28a745;
+ stroke-opacity: 1;
+ stroke-width: 2px;
+}
+.line-graph .path {
+ fill: none;
+ stroke: #28a745;
+ stroke-opacity: 1;
+ stroke-width: 2px;
+}
+line.dashed {
+ stroke-dasharray: 5,3;
+}
+.tick.x-axis-label {
+ display: block;
+}
+.tick .specific-value {
+ text-anchor: start;
+}
+.tick .y-value-text {
+ text-anchor: end;
+}
+.tick .x-value-text {
+ text-anchor: middle;
+}
body[data-route^="Form/Communication"] textarea[data-fieldname="subject"] {
height: 80px !important;
}
diff --git a/frappe/public/css/list.css b/frappe/public/css/list.css
index a13ece1c23..2f342af46f 100644
--- a/frappe/public/css/list.css
+++ b/frappe/public/css/list.css
@@ -232,6 +232,7 @@
padding: 2px 4px;
font-weight: normal;
background-color: #F0F4F7;
+ white-space: normal;
}
.taggle_list .taggle:hover {
padding: 2px 15px 2px 4px;
@@ -448,6 +449,7 @@
.list-item__content--activity {
justify-content: flex-end;
margin-right: 5px;
+ min-width: 110px;
}
.list-item__content--activity .list-row-modified,
.list-item__content--activity .avatar-small {
diff --git a/frappe/public/css/mobile.css b/frappe/public/css/mobile.css
index 5d954a9ce2..9e0bbe3597 100644
--- a/frappe/public/css/mobile.css
+++ b/frappe/public/css/mobile.css
@@ -192,6 +192,15 @@ body {
}
}
@media (max-width: 767px) {
+ body[data-route^="Form"] .page-title .title-text {
+ font-size: 16px;
+ width: calc(100% - 30px);
+ }
+ body[data-route^="Form"] .page-title .indicator {
+ float: left;
+ margin-top: 10px;
+ margin-right: 5px;
+ }
.modal .modal-dialog {
margin: 0px;
padding: 0px;
diff --git a/frappe/public/css/page.css b/frappe/public/css/page.css
index a7e89d00cd..f5ccdc5a6a 100644
--- a/frappe/public/css/page.css
+++ b/frappe/public/css/page.css
@@ -146,6 +146,9 @@ select.input-sm {
right: 101px;
width: 100%;
}
+ .page-title h1 {
+ padding-right: 170px;
+ }
.page-head .page-title h1 {
font-size: 18px;
}
diff --git a/frappe/public/css/variables.css b/frappe/public/css/variables.css
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/public/js/frappe/dom.js b/frappe/public/js/frappe/dom.js
index 5f35dc83c9..c66841ceee 100644
--- a/frappe/public/js/frappe/dom.js
+++ b/frappe/public/js/frappe/dom.js
@@ -211,6 +211,9 @@ frappe.timeout = seconds => {
});
};
+frappe.scrub = function(text) {
+ return text.replace(/ /g, "_").toLowerCase();
+};
frappe.get_modal = function(title, content) {
return $(frappe.render_template("modal", {title:title, content:content})).appendTo(document.body);
diff --git a/frappe/public/js/frappe/form/dashboard.js b/frappe/public/js/frappe/form/dashboard.js
index fc3c31fce2..d27aa619e0 100644
--- a/frappe/public/js/frappe/form/dashboard.js
+++ b/frappe/public/js/frappe/form/dashboard.js
@@ -11,7 +11,7 @@ frappe.ui.form.Dashboard = Class.extend({
this.progress_area = this.wrapper.find(".progress-area");
this.heatmap_area = this.wrapper.find('.form-heatmap');
- this.chart_area = this.wrapper.find('.form-chart');
+ this.graph_area = this.wrapper.find('.form-graph');
this.stats_area = this.wrapper.find('.form-stats');
this.stats_area_row = this.stats_area.find('.row');
this.links_area = this.wrapper.find('.form-links');
@@ -43,9 +43,9 @@ frappe.ui.form.Dashboard = Class.extend({
this.frm.layout.show_message();
},
- add_comment: function(text, permanent) {
+ add_comment: function(text, alert_class, permanent) {
var me = this;
- this.set_headline_alert(text);
+ this.set_headline_alert(text, alert_class);
if(!permanent) {
setTimeout(function() {
me.clear_headline();
@@ -91,6 +91,7 @@ frappe.ui.form.Dashboard = Class.extend({
this.show();
},
+
format_percent: function(title, percent) {
var width = cint(percent) < 1 ? 1 : cint(percent);
var progress_class = "";
@@ -138,6 +139,11 @@ frappe.ui.form.Dashboard = Class.extend({
show = true;
}
+ if(this.data.graph) {
+ this.setup_graph();
+ show = true;
+ }
+
if(show) {
this.show();
}
@@ -383,13 +389,50 @@ frappe.ui.form.Dashboard = Class.extend({
},
//graphs
+ setup_graph: function() {
+ var me = this;
+
+ var method = this.data.graph_method;
+ var args = {
+ doctype: this.frm.doctype,
+ docname: this.frm.doc.name,
+ };
+
+ $.extend(args, this.data.graph_method_args);
+
+ frappe.call({
+ type: "GET",
+ method: method,
+ args: args,
+
+ callback: function(r) {
+ if(r.message) {
+ me.render_graph(r.message);
+ }
+ }
+ });
+ },
+
+ render_graph: function(args) {
+ var me = this;
+ this.graph_area.empty().removeClass('hidden');
+ $.extend(args, {
+ parent: me.graph_area,
+ width: 710,
+ height: 140,
+ mode: 'line-graph'
+ });
+
+ new frappe.ui.Graph(args);
+ },
+
setup_chart: function(opts) {
var me = this;
- this.chart_area.removeClass('hidden');
+ this.graph_area.removeClass('hidden');
$.extend(opts, {
- wrapper: me.wrapper.find('.form-chart'),
+ wrapper: me.graph_area,
padding: {
right: 30,
bottom: 30
diff --git a/frappe/public/js/frappe/form/templates/form_dashboard.html b/frappe/public/js/frappe/form/templates/form_dashboard.html
index b1865a9c94..c41929df73 100644
--- a/frappe/public/js/frappe/form/templates/form_dashboard.html
+++ b/frappe/public/js/frappe/form/templates/form_dashboard.html
@@ -5,7 +5,7 @@
-
+
diff --git a/frappe/public/js/frappe/form/templates/form_sidebar.html b/frappe/public/js/frappe/form/templates/form_sidebar.html
index e44a55cedc..398d6a50d1 100644
--- a/frappe/public/js/frappe/form/templates/form_sidebar.html
+++ b/frappe/public/js/frappe/form/templates/form_sidebar.html
@@ -10,7 +10,11 @@
{% if frm.meta.beta %}
{% endif %}