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()) + 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 - cmd = [ - './node_modules/.bin/nightwatch', - '--config', './apps/frappe/frappe/nightwatch.js', - '--app', app, - '--site', site - ] - - if ci: - cmd.extend(['--env', 'ci_server']) - - 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 + 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) + 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);
+ QUnit.test("Test quick entry", function(assert) {
+ assert.expect(2);
+ let done = assert.async();
+ let random_text = 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();
- });
- });
+ 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:
+
+ has_inline_images = re.search('''embed=['"].*?['"]''', message)
+
+ if has_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)
-
- _inline_images.append({
- 'filename': image.get('filename'),
- 'filecontent': image.get('filecontent'),
- 'content_id': content_id
- })
+ 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 This is embedded image you asked for
-