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 = '''
- test + test +
''' + message, inline_images = replace_filename_with_cid(original_message) + processed_message = '''
- test + test +
- ''' - 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 %}