Browse Source

Merge branch 'develop' into twofactor

version-14
ckosiegbu 8 years ago
parent
commit
03718b9ba3
100 changed files with 3312 additions and 1640 deletions
  1. +2
    -1
      .eslintrc
  2. +17
    -11
      .travis.yml
  3. +18
    -11
      frappe/__init__.py
  4. +1
    -0
      frappe/build.js
  5. +16
    -19
      frappe/commands/utils.py
  6. +7
    -0
      frappe/contacts/doctype/address/address.js
  7. +7
    -0
      frappe/contacts/doctype/address/address.py
  8. +3
    -0
      frappe/contacts/doctype/contact/contact_list.js
  9. +1
    -0
      frappe/contacts/doctype/contact/test_records.json
  10. +8
    -0
      frappe/contacts/doctype/salutation/test_records.json
  11. +7
    -6
      frappe/core/doctype/authentication_log/test_authentication_log.py
  12. +46
    -2
      frappe/core/doctype/docfield/docfield.json
  13. +17
    -6
      frappe/core/doctype/doctype/doctype.py
  14. +1
    -1
      frappe/core/doctype/report/report.py
  15. +33
    -1
      frappe/core/doctype/system_settings/system_settings.json
  16. +0
    -0
      frappe/core/doctype/test_runner/__init__.py
  17. +74
    -0
      frappe/core/doctype/test_runner/test_runner.js
  18. +122
    -0
      frappe/core/doctype/test_runner/test_runner.json
  19. +49
    -0
      frappe/core/doctype/test_runner/test_runner.py
  20. +7
    -0
      frappe/core/doctype/user/test_records.json
  21. +1
    -1
      frappe/core/doctype/user/user.json
  22. +6
    -6
      frappe/core/doctype/user/user.py
  23. +1
    -1
      frappe/core/page/permission_manager/permission_manager_help.html
  24. +1
    -1
      frappe/custom/doctype/custom_field/custom_field.js
  25. +3
    -2
      frappe/custom/doctype/custom_field/custom_field.json
  26. +4
    -0
      frappe/custom/doctype/custom_field/custom_field.py
  27. +1
    -1
      frappe/custom/doctype/customize_form/customize_form.js
  28. +6
    -0
      frappe/custom/doctype/customize_form/customize_form.py
  29. +5
    -5
      frappe/custom/doctype/customize_form/test_customize_form.py
  30. +2
    -2
      frappe/custom/doctype/customize_form_field/customize_form_field.json
  31. +2
    -2
      frappe/desk/doctype/event/event.json
  32. +20
    -4
      frappe/desk/doctype/todo/todo.json
  33. +2
    -1
      frappe/desk/form/meta.py
  34. +1
    -1
      frappe/desk/page/applications/application_row.html
  35. +1
    -1
      frappe/desk/page/backups/backups.html
  36. +7
    -0
      frappe/desk/page/backups/backups.js
  37. +28
    -1
      frappe/desk/page/backups/backups.py
  38. +112
    -7
      frappe/desk/page/setup_wizard/setup_wizard.css
  39. +352
    -197
      frappe/desk/page/setup_wizard/setup_wizard.js
  40. +17
    -1
      frappe/desk/page/setup_wizard/setup_wizard.py
  41. +8
    -4
      frappe/desk/page/setup_wizard/setup_wizard_page.html
  42. +1
    -1
      frappe/desk/query_report.py
  43. BIN
      frappe/docs/assets/img/app-development/test-runner.png
  44. +0
    -0
      frappe/docs/user/en/guides/automated-testing/__init__.py
  45. +7
    -0
      frappe/docs/user/en/guides/automated-testing/index.md
  46. +3
    -0
      frappe/docs/user/en/guides/automated-testing/index.txt
  47. +49
    -0
      frappe/docs/user/en/guides/automated-testing/integration-testing.md
  48. +70
    -0
      frappe/docs/user/en/guides/automated-testing/qunit-testing.md
  49. +16
    -22
      frappe/docs/user/en/guides/automated-testing/unit-testing.md
  50. +1
    -1
      frappe/docs/user/en/guides/basics/apps.md
  51. +1
    -1
      frappe/docs/user/fr/tutorial/web-views.md
  52. +2
    -2
      frappe/email/doctype/auto_email_report/auto_email_report.json
  53. +11
    -7
      frappe/email/doctype/auto_email_report/auto_email_report.py
  54. +1
    -1
      frappe/email/doctype/auto_email_report/test_auto_email_report.py
  55. +2
    -1
      frappe/email/doctype/email_account/email_account.js
  56. +2
    -1
      frappe/email/doctype/email_account/email_account.py
  57. +1
    -0
      frappe/email/doctype/email_alert/email_alert.js
  58. +141
    -4
      frappe/email/doctype/email_alert/email_alert.json
  59. +5
    -0
      frappe/email/doctype/email_alert/email_alert.py
  60. +6
    -1
      frappe/email/doctype/email_alert/test_email_alert.py
  61. +3
    -1
      frappe/email/doctype/email_alert/test_records.json
  62. +1
    -1
      frappe/email/doctype/email_group/email_group.py
  63. +49
    -2
      frappe/email/doctype/email_queue/email_queue.json
  64. +3
    -2
      frappe/email/doctype/newsletter/newsletter.py
  65. +175
    -65
      frappe/email/email_body.py
  66. +56
    -17
      frappe/email/queue.py
  67. +101
    -0
      frappe/email/test_email_body.py
  68. +1
    -1
      frappe/frappeclient.py
  69. +3
    -0
      frappe/integrations/doctype/dropbox_settings/dropbox_settings.py
  70. +1
    -2
      frappe/model/base_document.py
  71. +10
    -5
      frappe/model/db_query.py
  72. +9
    -1
      frappe/model/db_schema.py
  73. +1
    -0
      frappe/model/delete_doc.py
  74. +6
    -0
      frappe/model/naming.py
  75. +1
    -0
      frappe/model/rename_doc.py
  76. +2
    -2
      frappe/modules/utils.py
  77. +0
    -12
      frappe/nightwatch.global.js
  78. +0
    -96
      frappe/nightwatch.js
  79. +2
    -0
      frappe/patches.txt
  80. +6
    -0
      frappe/patches/v8_1/delete_custom_docperm_if_doctype_not_exists.py
  81. +14
    -0
      frappe/patches/v8_1/update_format_options_in_auto_email_report.py
  82. +1
    -1
      frappe/printing/doctype/print_format/test_print_format.py
  83. +3
    -0
      frappe/public/build.json
  84. +48
    -0
      frappe/public/css/desk.css
  85. +20
    -17
      frappe/public/css/form.css
  86. +20
    -0
      frappe/public/js/frappe/dom.js
  87. +428
    -266
      frappe/public/js/frappe/form/control.js
  88. +12
    -15
      frappe/public/js/frappe/form/dashboard.js
  89. +3
    -3
      frappe/public/js/frappe/form/footer/timeline.js
  90. +3
    -2
      frappe/public/js/frappe/form/footer/timeline_item.html
  91. +21
    -0
      frappe/public/js/frappe/form/formatters.js
  92. +34
    -687
      frappe/public/js/frappe/form/grid.js
  93. +586
    -0
      frappe/public/js/frappe/form/grid_row.js
  94. +97
    -0
      frappe/public/js/frappe/form/grid_row_form.js
  95. +83
    -31
      frappe/public/js/frappe/form/layout.js
  96. +86
    -50
      frappe/public/js/frappe/form/quick_entry.js
  97. +19
    -3
      frappe/public/js/frappe/form/save.js
  98. +67
    -15
      frappe/public/js/frappe/form/script_manager.js
  99. +1
    -3
      frappe/public/js/frappe/form/templates/form_dashboard.html
  100. +1
    -2
      frappe/public/js/frappe/form/templates/form_links.html

+ 2
- 1
.eslintrc View File

@@ -117,6 +117,7 @@
"set_field_options": true, "set_field_options": true,
"getCookie": true, "getCookie": true,
"getCookies": true, "getCookies": true,
"get_url_arg": true
"get_url_arg": true,
"QUnit": true
} }
} }

+ 17
- 11
.travis.yml View File

@@ -1,9 +1,5 @@
language: python language: python
dist: trusty dist: trusty
group: deprecated-2017Q2

python:
- "2.7"


addons: addons:
apt: apt:
@@ -12,14 +8,14 @@ addons:
packages: packages:
- google-chrome-stable - google-chrome-stable


python:
- "2.7"

services: services:
- mysql - mysql


before_install:
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start

install: install:
- sudo rm /etc/apt/sources.list.d/docker.list
- sudo apt-get purge -y mysql-common mysql-server mysql-client - sudo apt-get purge -y mysql-common mysql-server mysql-client
- nvm install v7.10.0 - nvm install v7.10.0
- wget https://raw.githubusercontent.com/frappe/bench/master/playbooks/install.py - wget https://raw.githubusercontent.com/frappe/bench/master/playbooks/install.py
@@ -31,18 +27,28 @@ install:
- cp -r $TRAVIS_BUILD_DIR/test_sites/test_site ~/frappe-bench/sites/ - cp -r $TRAVIS_BUILD_DIR/test_sites/test_site ~/frappe-bench/sites/


before_script: before_script:
- wget http://chromedriver.storage.googleapis.com/2.27/chromedriver_linux64.zip
- unzip chromedriver_linux64.zip
- sudo apt-get install libnss3
- sudo apt-get --only-upgrade install google-chrome-stable
- sudo cp chromedriver /usr/local/bin/.
- sudo chmod +x /usr/local/bin/chromedriver
- export DISPLAY=:99.0
- sh -e /etc/init.d/xvfb start
- sleep 3
- mysql -u root -ptravis -e 'create database test_frappe' - mysql -u root -ptravis -e 'create database test_frappe'
- echo "USE mysql;\nCREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe';\nFLUSH PRIVILEGES;\n" | mysql -u root -ptravis - echo "USE mysql;\nCREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe';\nFLUSH PRIVILEGES;\n" | mysql -u root -ptravis
- echo "USE mysql;\nGRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost';\n" | mysql -u root -ptravis - echo "USE mysql;\nGRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost';\n" | mysql -u root -ptravis
- cd ~/frappe-bench - cd ~/frappe-bench
- bench use test_site - bench use test_site
- bench reinstall --yes - bench reinstall --yes
- bench scheduler disable
- bench start & - bench start &
- sleep 10 - sleep 10


script: script:
- set -e - set -e
- bench --verbose run-tests - bench --verbose run-tests
- bench reinstall --yes
- bench run-ui-tests --ci
- sleep 5
- bench --verbose run-ui-tests --app frappe

+ 18
- 11
frappe/__init__.py View File

@@ -12,9 +12,9 @@ import os, sys, importlib, inspect, json


# public # public
from .exceptions import * from .exceptions import *
from .utils.jinja import get_jenv, get_template, render_template
from .utils.jinja import get_jenv, get_template, render_template, get_email_from_template


__version__ = '8.2.2'
__version__ = '8.4.0'
__title__ = "Frappe Framework" __title__ = "Frappe Framework"


local = Local() local = Local()
@@ -138,8 +138,7 @@ def init(site, sites_path=None, new_site=False):


local.module_app = None local.module_app = None
local.app_modules = None local.app_modules = None
local.system_settings = None
local.system_country = None
local.system_settings = _dict()


local.user = None local.user = None
local.user_perms = None local.user_perms = None
@@ -381,7 +380,7 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message
attachments=None, content=None, doctype=None, name=None, reply_to=None, 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, 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, send_priority=1, communication=None, retry=1, now=None, read_receipt=None, is_notification=False,
inline_images=None):
inline_images=None, template=None, args=None, header=False):
"""Send email using user's default **Email Account** or global default **Email Account**. """Send email using user's default **Email Account** or global default **Email Account**.




@@ -404,7 +403,15 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message
:param expose_recipients: Display all recipients in the footer message - "This email was sent to" :param expose_recipients: Display all recipients in the footer message - "This email was sent to"
:param communication: Communication link to be set in Email Queue record :param communication: Communication link to be set in Email Queue record
:param inline_images: List of inline images as {"filename", "filecontent"}. All src properties will be replaced with random Content-Id :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
if template:
message, text_content = get_email_from_template(template, args)

message = content or message message = content or message


if as_markdown: if as_markdown:
@@ -416,13 +423,13 @@ def sendmail(recipients=[], sender="", subject="No Subject", message="No Message


import email.queue import email.queue
email.queue.send(recipients=recipients, sender=sender, email.queue.send(recipients=recipients, sender=sender,
subject=subject, message=message,
subject=subject, message=message, text_content=text_content,
reference_doctype = doctype or reference_doctype, reference_name = name or reference_name, reference_doctype = doctype or reference_doctype, reference_name = name or reference_name,
unsubscribe_method=unsubscribe_method, unsubscribe_params=unsubscribe_params, unsubscribe_message=unsubscribe_message, unsubscribe_method=unsubscribe_method, unsubscribe_params=unsubscribe_params, unsubscribe_message=unsubscribe_message,
attachments=attachments, reply_to=reply_to, cc=cc, message_id=message_id, in_reply_to=in_reply_to, 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, send_after=send_after, expose_recipients=expose_recipients, send_priority=send_priority,
communication=communication, now=now, read_receipt=read_receipt, is_notification=is_notification, communication=communication, now=now, read_receipt=read_receipt, is_notification=is_notification,
inline_images=inline_images)
inline_images=inline_images, header=header)


whitelisted = [] whitelisted = []
guest_methods = [] guest_methods = []
@@ -1364,7 +1371,7 @@ def get_active_domains():


return active_domains return active_domains


def get_system_country():
if local.system_country is None:
local.system_country = db.get_single_value('System Settings', 'country') or ''
return local.system_country
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)})
return local.system_settings.get(key)

+ 1
- 0
frappe/build.js View File

@@ -272,6 +272,7 @@ function watch_js(ondirty) {
if (sources.includes(filename)) { if (sources.includes(filename)) {
pack(target, sources); pack(target, sources);
ondirty && ondirty(target); ondirty && ondirty(target);
// break;
} }
} }
}); });


+ 16
- 19
frappe/commands/utils.py View File

@@ -298,11 +298,13 @@ def console(context):
@click.option('--doctype', help="For DocType") @click.option('--doctype', help="For DocType")
@click.option('--test', multiple=True, help="Specific test") @click.option('--test', multiple=True, help="Specific test")
@click.option('--driver', help="For Travis") @click.option('--driver', help="For Travis")
@click.option('--ui-tests', is_flag=True, default=False, help="Run UI Tests")
@click.option('--module', help="Run tests in a module") @click.option('--module', help="Run tests in a module")
@click.option('--profile', is_flag=True, default=False) @click.option('--profile', is_flag=True, default=False)
@click.option('--junit-xml-output', help="Destination file path for junit xml report") @click.option('--junit-xml-output', help="Destination file path for junit xml report")
@pass_context @pass_context
def run_tests(context, app=None, module=None, doctype=None, test=(), driver=None, profile=False, junit_xml_output=False):
def run_tests(context, app=None, module=None, doctype=None, test=(),
driver=None, profile=False, junit_xml_output=False, ui_tests = False):
"Run tests" "Run tests"
import frappe.test_runner import frappe.test_runner
tests = test tests = test
@@ -311,7 +313,8 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), driver=None
frappe.init(site=site) frappe.init(site=site)


ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests, ret = frappe.test_runner.main(app, module, doctype, context.verbose, tests=tests,
force=context.force, profile=profile, junit_xml_output=junit_xml_output)
force=context.force, profile=profile, junit_xml_output=junit_xml_output,
ui_tests = ui_tests)
if len(ret.failures) == 0 and len(ret.errors) == 0: if len(ret.failures) == 0 and len(ret.errors) == 0:
ret = 0 ret = 0


@@ -320,30 +323,24 @@ def run_tests(context, app=None, module=None, doctype=None, test=(), driver=None


@click.command('run-ui-tests') @click.command('run-ui-tests')
@click.option('--app', help="App to run tests on, leave blank for all apps") @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 @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" "Run UI tests"
import subprocess
import frappe.test_runner


site = get_site(context) site = get_site(context)
frappe.init(site=site) 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.command('serve')
@click.option('--port', default=8000) @click.option('--port', default=8000)


+ 7
- 0
frappe/contacts/doctype/address/address.js View File

@@ -30,5 +30,12 @@ frappe.ui.form.on("Address", {
frappe.model.remove_from_locals(d.link_doctype, d.link_name); frappe.model.remove_from_locals(d.link_doctype, d.link_name);
}); });
} }
},
after_save: function() {
var last_route = frappe.route_history.slice(-2, -1)[0];
if(frappe.dynamic_link && frappe.dynamic_link.doc
&& frappe.dynamic_link.doc.name == last_route[2]){
frappe.set_route(last_route[0], last_route[1], last_route[2]);
}
} }
}); });

+ 7
- 0
frappe/contacts/doctype/address/address.py View File

@@ -185,6 +185,13 @@ def get_shipping_address(company):
address_as_dict = address[0] address_as_dict = address[0]
name, address_template = get_address_templates(address_as_dict) name, address_template = get_address_templates(address_as_dict)
return address_as_dict.get("name"), frappe.render_template(address_template, 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): def address_query(doctype, txt, searchfield, start, page_len, filters):
from frappe.desk.reportview import get_match_cond from frappe.desk.reportview import get_match_cond


+ 3
- 0
frappe/contacts/doctype/contact/contact_list.js View File

@@ -0,0 +1,3 @@
frappe.listview_settings['Contact'] = {
add_fields: ["image"],
};

+ 1
- 0
frappe/contacts/doctype/contact/test_records.json View File

@@ -1,6 +1,7 @@
[ [
{ {
"doctype": "Contact", "doctype": "Contact",
"salutation": "Mr",
"email_id": "test_conctact@example.com", "email_id": "test_conctact@example.com",
"first_name": "_Test Contact For _Test Customer", "first_name": "_Test Contact For _Test Customer",
"is_primary_contact": 1, "is_primary_contact": 1,


+ 8
- 0
frappe/contacts/doctype/salutation/test_records.json View File

@@ -0,0 +1,8 @@
[
{
"salutation": "Mr"
},
{
"salutation": "Mrs"
}
]

+ 7
- 6
frappe/core/doctype/authentication_log/test_authentication_log.py View File

@@ -13,13 +13,12 @@ class TestAuthenticationLog(unittest.TestCase):
from frappe.auth import LoginManager, CookieManager from frappe.auth import LoginManager, CookieManager


# test user login log # test user login log
frappe.local.form_dict = { 'cmd': 'login' }

frappe.form_dict = {
frappe.local.form_dict = frappe._dict({
'cmd': 'login',
'sid': 'Guest', 'sid': 'Guest',
'pwd': 'admin', 'pwd': 'admin',
'usr': 'Administrator' 'usr': 'Administrator'
}
})


frappe.local.cookie_manager = CookieManager() frappe.local.cookie_manager = CookieManager()
frappe.local.login_manager = LoginManager() frappe.local.login_manager = LoginManager()
@@ -38,9 +37,11 @@ class TestAuthenticationLog(unittest.TestCase):
auth_log = self.get_auth_log() auth_log = self.get_auth_log()
self.assertEquals(auth_log.status, 'Failed') self.assertEquals(auth_log.status, 'Failed')


frappe.local.form_dict = frappe._dict()

def get_auth_log(self, operation='Login'): def get_auth_log(self, operation='Login'):
names = frappe.db.sql_list("""select name from `tabAuthentication Log`
where user='Administrator' and operation='{operation}' order by
names = frappe.db.sql_list("""select name from `tabAuthentication Log`
where user='Administrator' and operation='{operation}' order by
creation desc""".format(operation=operation)) creation desc""".format(operation=operation))


name = names[0] name = names[0]


+ 46
- 2
frappe/core/doctype/docfield/docfield.json View File

@@ -11,8 +11,10 @@
"doctype": "DocType", "doctype": "DocType",
"document_type": "Setup", "document_type": "Setup",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB",
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -41,6 +43,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 1, "bold": 1,
"collapsible": 0, "collapsible": 0,
@@ -73,6 +76,7 @@
"width": "163" "width": "163"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 1, "bold": 1,
"collapsible": 0, "collapsible": 0,
@@ -92,7 +96,7 @@
"no_copy": 0, "no_copy": 0,
"oldfieldname": "fieldtype", "oldfieldname": "fieldtype",
"oldfieldtype": "Select", "oldfieldtype": "Select",
"options": "Attach\nAttach Image\nButton\nCheck\nCode\nColumn Break\nCurrency\nData\nDate\nDatetime\nDynamic Link\nFloat\nFold\nHeading\nHTML\nImage\nInt\nLink\nLong Text\nPassword\nPercent\nRead Only\nSection Break\nSelect\nSmall Text\nTable\nText\nText Editor\nTime\nSignature",
"options": "Attach\nAttach Image\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDynamic Link\nFloat\nFold\nHeading\nHTML\nImage\nInt\nLink\nLong Text\nPassword\nPercent\nRead Only\nSection Break\nSelect\nSmall Text\nTable\nText\nText Editor\nTime\nSignature",
"permlevel": 0, "permlevel": 0,
"print_hide": 0, "print_hide": 0,
"print_hide_if_no_value": 0, "print_hide_if_no_value": 0,
@@ -105,6 +109,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 1, "bold": 1,
"collapsible": 0, "collapsible": 0,
@@ -135,6 +140,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -167,6 +173,7 @@
"width": "50px" "width": "50px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -198,6 +205,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -228,6 +236,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -260,6 +269,7 @@
"width": "50px" "width": "50px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -290,6 +300,7 @@
"width": "70px" "width": "70px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -319,6 +330,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -349,6 +361,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -378,6 +391,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -408,6 +422,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -438,6 +453,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -465,6 +481,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -496,6 +513,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -526,6 +544,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -554,6 +573,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -584,6 +604,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -616,6 +637,7 @@
"width": "50px" "width": "50px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -646,6 +668,7 @@
"width": "50px" "width": "50px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -675,6 +698,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -704,6 +728,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -734,6 +759,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -761,6 +787,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -794,6 +821,7 @@
"width": "50px" "width": "50px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -823,6 +851,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -855,6 +884,7 @@
"width": "50px" "width": "50px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -887,6 +917,7 @@
"width": "50px" "width": "50px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -917,6 +948,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -947,6 +979,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -975,6 +1008,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -1007,6 +1041,7 @@
"width": "50px" "width": "50px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -1039,6 +1074,7 @@
"width": "50px" "width": "50px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -1071,6 +1107,7 @@
"width": "50px" "width": "50px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -1101,6 +1138,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -1129,6 +1167,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -1161,6 +1200,7 @@
"width": "50px" "width": "50px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -1192,6 +1232,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -1219,6 +1260,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -1251,6 +1293,7 @@
"width": "300px" "width": "300px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -1280,6 +1323,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -1319,7 +1363,7 @@
"issingle": 0, "issingle": 0,
"istable": 1, "istable": 1,
"max_attachments": 0, "max_attachments": 0,
"modified": "2017-04-21 16:56:04.023296",
"modified": "2017-07-06 12:36:21.248293",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Core", "module": "Core",
"name": "DocField", "name": "DocField",


+ 17
- 6
frappe/core/doctype/doctype/doctype.py View File

@@ -14,7 +14,7 @@ from frappe.model.document import Document
from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.desk.notifications import delete_notification_count_for from frappe.desk.notifications import delete_notification_count_for
from frappe.modules import make_boilerplate from frappe.modules import make_boilerplate
from frappe.model.db_schema import validate_column_name
from frappe.model.db_schema import validate_column_name, validate_column_length
import frappe.website.render import frappe.website.render


class InvalidFieldNameError(frappe.ValidationError): pass class InvalidFieldNameError(frappe.ValidationError): pass
@@ -78,7 +78,7 @@ class DocType(Document):
if not [d.fieldname for d in self.fields if d.in_list_view]: if not [d.fieldname for d in self.fields if d.in_list_view]:
cnt = 0 cnt = 0
for d in self.fields: for d in self.fields:
if d.reqd and not d.hidden:
if d.reqd and not d.hidden and not d.fieldtype == "Table":
d.in_list_view = 1 d.in_list_view = 1
cnt += 1 cnt += 1
if cnt == 4: break if cnt == 4: break
@@ -385,9 +385,10 @@ def validate_fields(meta):


1. There are no illegal characters in fieldnames 1. There are no illegal characters in fieldnames
2. If fieldnames are unique. 2. If fieldnames are unique.
3. Fields that do have database columns are not mandatory.
4. `Link` and `Table` options are valid.
5. **Hidden** and **Mandatory** are not set simultaneously.
3. Validate column length.
4. Fields that do have database columns are not mandatory.
5. `Link` and `Table` options are valid.
6. **Hidden** and **Mandatory** are not set simultaneously.
7. `Check` type field has default as 0 or 1. 7. `Check` type field has default as 0 or 1.
8. `Dynamic Links` are correctly defined. 8. `Dynamic Links` are correctly defined.
9. Precision is set in numeric fields and is between 1 & 6. 9. Precision is set in numeric fields and is between 1 & 6.
@@ -406,6 +407,9 @@ def validate_fields(meta):
if len(duplicates) > 1: if len(duplicates) > 1:
frappe.throw(_("Fieldname {0} appears multiple times in rows {1}").format(fieldname, ", ".join(duplicates))) frappe.throw(_("Fieldname {0} appears multiple times in rows {1}").format(fieldname, ", ".join(duplicates)))


def check_fieldname_length(fieldname):
validate_column_length(fieldname)

def check_illegal_mandatory(d): def check_illegal_mandatory(d):
if (d.fieldtype in no_value_fields) and d.fieldtype!="Table" and d.reqd: if (d.fieldtype in no_value_fields) and d.fieldtype!="Table" and d.reqd:
frappe.throw(_("Field {0} of type {1} cannot be mandatory").format(d.label, d.fieldtype)) frappe.throw(_("Field {0} of type {1} cannot be mandatory").format(d.label, d.fieldtype))
@@ -581,7 +585,6 @@ def validate_fields(meta):
frappe.throw(_("Sort field {0} must be a valid fieldname").format(fieldname), frappe.throw(_("Sort field {0} must be a valid fieldname").format(fieldname),
InvalidFieldNameError) InvalidFieldNameError)



fields = meta.get("fields") fields = meta.get("fields")
fieldname_list = [d.fieldname for d in fields] fieldname_list = [d.fieldname for d in fields]


@@ -598,6 +601,7 @@ def validate_fields(meta):
d.fieldname = d.fieldname.lower() d.fieldname = d.fieldname.lower()
check_illegal_characters(d.fieldname) check_illegal_characters(d.fieldname)
check_unique_fieldname(d.fieldname) check_unique_fieldname(d.fieldname)
check_fieldname_length(d.fieldname)
check_illegal_mandatory(d) check_illegal_mandatory(d)
check_link_table_options(d) check_link_table_options(d)
check_dynamic_link_options(d) check_dynamic_link_options(d)
@@ -766,3 +770,10 @@ def init_list(doctype):
doc = frappe.get_meta(doctype) doc = frappe.get_meta(doctype)
make_boilerplate("controller_list.js", doc) make_boilerplate("controller_list.js", doc)
make_boilerplate("controller_list.html", doc) make_boilerplate("controller_list.html", doc)

def check_if_fieldname_conflicts_with_methods(doctype, fieldname):
doc = frappe.get_doc({"doctype": doctype})
method_list = [method for method in dir(doc) if isinstance(method, str) and callable(getattr(doc, method))]

if fieldname in method_list:
frappe.throw(_("Fieldname {0} conflicting with meta object").format(fieldname))

+ 1
- 1
frappe/core/doctype/report/report.py View File

@@ -49,7 +49,7 @@ class Report(Document):
delete_custom_role('report', self.name) delete_custom_role('report', self.name)


def set_doctype_roles(self): def set_doctype_roles(self):
if not self.get('roles'):
if not self.get('roles') and self.is_standard == 'No':
meta = frappe.get_meta(self.ref_doctype) meta = frappe.get_meta(self.ref_doctype)
roles = [{'role': d.role} for d in meta.permissions if d.permlevel==0] roles = [{'role': d.role} for d in meta.permissions if d.permlevel==0]
self.set('roles', roles) self.set('roles', roles)


+ 33
- 1
frappe/core/doctype/system_settings/system_settings.json View File

@@ -810,7 +810,7 @@
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
"columns": 0, "columns": 0,
"description": "eg. If Apply User Permissions is checked for Report DocType but no User Permissions are defined for Report for a User, then all Reports are shown to that User",
"description": "If Apply User Permissions is checked for Report DocType but no User Permissions are defined for Report for a User, then all Reports are shown to that User",
"fieldname": "ignore_user_permissions_if_missing", "fieldname": "ignore_user_permissions_if_missing",
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 0, "hidden": 0,
@@ -835,6 +835,38 @@
"set_only_once": 0, "set_only_once": 0,
"unique": 0 "unique": 0
}, },
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"default": "0",
"description": "If Apply Strict User Permission is checked and User Permission is defined for a DocType for a User, then all the documents where value of the link is blank, will not be shown to that User",
"fieldname": "apply_strict_user_permissions",
"fieldtype": "Check",
"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": "Apply Strict User Permissions",
"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
},
{ {
"allow_bulk_edit": 0, "allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,


+ 0
- 0
frappe/core/doctype/test_runner/__init__.py View File


+ 74
- 0
frappe/core/doctype/test_runner/test_runner.js View File

@@ -0,0 +1,74 @@
// Copyright (c) 2017, Frappe Technologies and contributors
// For license information, please see license.txt

frappe.ui.form.on('Test Runner', {
refresh: (frm) => {
frm.disable_save();
frm.page.set_primary_action(__("Run Tests"), () => {
return new Promise(resolve => {
let wrapper = $(frm.fields_dict.output.wrapper).empty();
$("<p>Loading...</p>").appendTo(wrapper);

// all tests
frappe.call({
method: 'frappe.core.doctype.test_runner.test_runner.get_test_js'
}).always((data) => {
$("<div id='qunit'></div>").appendTo(wrapper.empty());
frm.events.run_tests(frm, data.message);
resolve();
});
});
});

},
run_tests: function(frm, files) {
let require_list = [
"assets/frappe/js/lib/jquery/qunit.js",
"assets/frappe/js/lib/jquery/qunit.css"
].concat();

frappe.require(require_list, () => {
files.forEach((f) => {
frappe.dom.eval(f.script);
});

// if(frm.doc.module_name) {
// QUnit.module.only(frm.doc.module_name);
// }

QUnit.testDone(function(details) {
var result = {
"Module name": details.module,
"Test name": details.name,
"Assertions": {
"Total": details.total,
"Passed": details.passed,
"Failed": details.failed
},
"Skipped": details.skipped,
"Todo": details.todo,
"Runtime": details.runtime
};

// eslint-disable-next-line
console.log(JSON.stringify(result, null, 2));
});
QUnit.load();

QUnit.done(({ total, failed, passed, runtime }) => {
// flag for selenium that test is done
$('<div id="frappe-qunit-done"></div>').appendTo($('body'));

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

}
});

+ 122
- 0
frappe/core/doctype/test_runner/test_runner.json View File

@@ -0,0 +1,122 @@
{
"allow_copy": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"beta": 0,
"creation": "2017-06-26 10:57:19.976624",
"custom": 0,
"docstatus": 0,
"doctype": "DocType",
"document_type": "",
"editable_grid": 1,
"engine": "InnoDB",
"fields": [
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "module_path",
"fieldtype": "Data",
"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": "Module Path",
"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
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "output",
"fieldtype": "HTML",
"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": "Output",
"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,
"idx": 0,
"image_view": 0,
"in_create": 0,
"is_submittable": 0,
"issingle": 1,
"istable": 0,
"max_attachments": 0,
"modified": "2017-07-12 23:16:15.910891",
"modified_by": "Administrator",
"module": "Core",
"name": "Test Runner",
"name_case": "",
"owner": "Administrator",
"permissions": [
{
"amend": 0,
"apply_user_permissions": 0,
"cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
"export": 0,
"if_owner": 0,
"import": 0,
"permlevel": 0,
"print": 1,
"read": 1,
"report": 0,
"role": "Administrator",
"set_user_permissions": 0,
"share": 1,
"submit": 0,
"write": 1
}
],
"quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1,
"track_seen": 0
}

+ 49
- 0
frappe/core/doctype/test_runner/test_runner.py View File

@@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017, Frappe Technologies and contributors
# For license information, please see license.txt

from __future__ import unicode_literals
import frappe, os
from frappe.model.document import Document

class TestRunner(Document):
pass

@frappe.whitelist()
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

+ 7
- 0
frappe/core/doctype/user/test_records.json View File

@@ -31,6 +31,13 @@
"new_password": "Eastern_43A1W", "new_password": "Eastern_43A1W",
"enabled": 1 "enabled": 1
}, },
{
"doctype": "User",
"email": "test3@example.com",
"first_name": "_Test3",
"new_password": "Eastern_43A1W",
"enabled": 1
},
{ {
"doctype": "User", "doctype": "User",
"email": "testperm@example.com", "email": "testperm@example.com",


+ 1
- 1
frappe/core/doctype/user/user.json View File

@@ -1949,7 +1949,7 @@
"precision": "", "precision": "",
"print_hide": 0, "print_hide": 0,
"print_hide_if_no_value": 0, "print_hide_if_no_value": 0,
"read_only": 0,
"read_only": 1,
"remember_last_selected_value": 0, "remember_last_selected_value": 0,
"report_hide": 0, "report_hide": 0,
"reqd": 0, "reqd": 0,


+ 6
- 6
frappe/core/doctype/user/user.py View File

@@ -225,11 +225,11 @@ class User(Document):


def password_reset_mail(self, link): def password_reset_mail(self, link):
self.send_login_mail(_("Password Reset"), 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): def password_update_mail(self, password):
self.send_login_mail(_("Password Update"), 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): def send_welcome_mail_to_user(self):
from frappe.utils import get_url from frappe.utils import get_url
@@ -248,7 +248,7 @@ class User(Document):
else: else:
subject = _("Complete Registration") subject = _("Complete Registration")


self.send_login_mail(subject, "templates/emails/new_user.html",
self.send_login_mail(subject, "new_user",
dict( dict(
link=link, link=link,
site_url=get_url(), 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 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, 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) delayed=(not now) if now!=None else self.flags.delay_emails, retry=3)


def a_system_manager_should_exist(self): def a_system_manager_should_exist(self):
@@ -579,7 +579,7 @@ def update_password(new_password, key=None, old_password=None):
def test_password_strength(new_password, key=None, old_password=None, user_data=[]): 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 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", "minimum_password_score"], as_dict=True) or {}


enable_password_policy = cint(password_policy.get("enable_password_policy", 0)) enable_password_policy = cint(password_policy.get("enable_password_policy", 0))
@@ -589,7 +589,7 @@ def test_password_strength(new_password, key=None, old_password=None, user_data=
return {} return {}


if not user_data: 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']) ['first_name', 'middle_name', 'last_name', 'email', 'birth_date'])


if new_password: if new_password:


+ 1
- 1
frappe/core/page/permission_manager/permission_manager_help.html View File

@@ -36,6 +36,6 @@
<li>{%= __("Apart from System Manager, roles with Set User Permissions right can set permissions for other users for that Document Type.") %}</li> <li>{%= __("Apart from System Manager, roles with Set User Permissions right can set permissions for other users for that Document Type.") %}</li>
</ol> </ol>
<p>{%= __("If these instructions where not helpful, please add in your suggestions on GitHub Issues.") %} <p>{%= __("If these instructions where not helpful, please add in your suggestions on GitHub Issues.") %}
<a href="https://github.com/frappe/frappe/issues" target="_blank">{%= __("Submit an Issue") %}</a>
<a href="https://github.com/frappe/frappe/issues" target="_blank" rel="noopener noreferrer">{%= __("Submit an Issue") %}</a>
</p> </p>
</div> </div>

+ 1
- 1
frappe/custom/doctype/custom_field/custom_field.js View File

@@ -11,7 +11,7 @@ frappe.ui.form.on('Custom Field', {
['DocType', 'issingle', '=', 0], ['DocType', 'issingle', '=', 0],
]; ];
if(frappe.session.user!=="Administrator") { if(frappe.session.user!=="Administrator") {
filters.push(['DocType', 'module', '!=', 'Core'])
filters.push(['DocType', 'module', 'not in', ['Core', 'Custom']])
} }
return { return {
"filters": filters "filters": filters


+ 3
- 2
frappe/custom/doctype/custom_field/custom_field.json View File

@@ -11,6 +11,7 @@
"doctype": "DocType", "doctype": "DocType",
"document_type": "Setup", "document_type": "Setup",
"editable_grid": 0, "editable_grid": 0,
"engine": "InnoDB",
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0, "allow_bulk_edit": 0,
@@ -219,7 +220,7 @@
"no_copy": 0, "no_copy": 0,
"oldfieldname": "fieldtype", "oldfieldname": "fieldtype",
"oldfieldtype": "Select", "oldfieldtype": "Select",
"options": "Attach\nAttach Image\nButton\nCheck\nCode\nColumn Break\nCurrency\nData\nDate\nDatetime\nDynamic Link\nFloat\nHTML\nImage\nInt\nLink\nLong Text\nPassword\nPercent\nRead Only\nSection Break\nSelect\nSmall Text\nTable\nText\nText Editor\nTime\nSignature",
"options": "Attach\nAttach Image\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDynamic Link\nFloat\nHTML\nImage\nInt\nLink\nLong Text\nPassword\nPercent\nRead Only\nSection Break\nSelect\nSmall Text\nTable\nText\nText Editor\nTime\nSignature",
"permlevel": 0, "permlevel": 0,
"print_hide": 0, "print_hide": 0,
"print_hide_if_no_value": 0, "print_hide_if_no_value": 0,
@@ -1160,7 +1161,7 @@
"issingle": 0, "issingle": 0,
"istable": 0, "istable": 0,
"max_attachments": 0, "max_attachments": 0,
"modified": "2017-06-13 09:52:49.692096",
"modified": "2017-07-06 17:23:43.835189",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom", "module": "Custom",
"name": "Custom Field", "name": "Custom Field",


+ 4
- 0
frappe/custom/doctype/custom_field/custom_field.py View File

@@ -39,6 +39,10 @@ class CustomField(Document):
if not self.fieldname: if not self.fieldname:
frappe.throw(_("Fieldname not set for Custom Field")) frappe.throw(_("Fieldname not set for Custom Field"))


if not self.flags.ignore_validate:
from frappe.core.doctype.doctype.doctype import check_if_fieldname_conflicts_with_methods
check_if_fieldname_conflicts_with_methods(self.dt, self.fieldname)

def on_update(self): def on_update(self):
frappe.clear_cache(doctype=self.dt) frappe.clear_cache(doctype=self.dt)
if not self.flags.ignore_validate: if not self.flags.ignore_validate:


+ 1
- 1
frappe/custom/doctype/customize_form/customize_form.js View File

@@ -15,7 +15,7 @@ frappe.ui.form.on("Customize Form", {
['DocType', 'custom', '=', 0], ['DocType', 'custom', '=', 0],
['DocType', 'name', 'not in', 'DocType, DocField, DocPerm, User, Role, Has Role, \ ['DocType', 'name', 'not in', 'DocType, DocField, DocPerm, User, Role, Has Role, \
Page, Has Role, Module Def, Print Format, Report, Customize Form, \ Page, Has Role, Module Def, Print Format, Report, Customize Form, \
Customize Form Field'],
Customize Form Field, Property Setter, Custom Field, Custom Script'],
['DocType', 'restrict_to_domain', 'in', frappe.boot.active_domains] ['DocType', 'restrict_to_domain', 'in', frappe.boot.active_domains]
] ]
}; };


+ 6
- 0
frappe/custom/doctype/customize_form/customize_form.py View File

@@ -68,6 +68,8 @@ allowed_fieldtype_change = (('Currency', 'Float', 'Percent'), ('Small Text', 'Da
('Text', 'Data'), ('Text', 'Text Editor', 'Code', 'Signature'), ('Data', 'Select'), ('Text', 'Data'), ('Text', 'Text Editor', 'Code', 'Signature'), ('Data', 'Select'),
('Text', 'Small Text')) ('Text', 'Small Text'))


allowed_fieldtype_for_options_change = ('Read Only', 'HTML', 'Select',)

class CustomizeForm(Document): class CustomizeForm(Document):
def on_update(self): def on_update(self):
frappe.db.sql("delete from tabSingles where doctype='Customize Form'") frappe.db.sql("delete from tabSingles where doctype='Customize Form'")
@@ -197,6 +199,10 @@ class CustomizeForm(Document):
frappe.msgprint(_("You cannot unset 'Read Only' for field {0}").format(df.label)) frappe.msgprint(_("You cannot unset 'Read Only' for field {0}").format(df.label))
continue continue


elif property == "options" and df.get("fieldtype") not in allowed_fieldtype_for_options_change:
frappe.msgprint(_("You can't set 'Options' for field {0}").format(df.label))
continue

self.make_property_setter(property=property, value=df.get(property), self.make_property_setter(property=property, value=df.get(property),
property_type=docfield_properties[property], fieldname=df.fieldname) property_type=docfield_properties[property], fieldname=df.fieldname)




+ 5
- 5
frappe/custom/doctype/customize_form/test_customize_form.py View File

@@ -165,22 +165,22 @@ class TestCustomizeForm(unittest.TestCase):
df = d.get("fields", {"fieldname": "title"})[0] df = d.get("fields", {"fieldname": "title"})[0]


# invalid fieldname # invalid fieldname
df.options = """{doc_type} - {introduction_test}"""
df.default = """{doc_type} - {introduction_test}"""
self.assertRaises(InvalidFieldNameError, d.run_method, "save_customization") self.assertRaises(InvalidFieldNameError, d.run_method, "save_customization")


# space in formatter # space in formatter
df.options = """{doc_type} - {introduction text}"""
df.default = """{doc_type} - {introduction text}"""
self.assertRaises(InvalidFieldNameError, d.run_method, "save_customization") self.assertRaises(InvalidFieldNameError, d.run_method, "save_customization")


# valid fieldname # valid fieldname
df.options = """{doc_type} - {introduction_text}"""
df.default = """{doc_type} - {introduction_text}"""
d.run_method("save_customization") d.run_method("save_customization")


# valid fieldname with escaped curlies # valid fieldname with escaped curlies
df.options = """{{ {doc_type} }} - {introduction_text}"""
df.default = """{{ {doc_type} }} - {introduction_text}"""
d.run_method("save_customization") d.run_method("save_customization")


# undo # undo
df.options = None
df.default = None
d.run_method("save_customization") d.run_method("save_customization")



+ 2
- 2
frappe/custom/doctype/customize_form_field/customize_form_field.json View File

@@ -94,7 +94,7 @@
"no_copy": 0, "no_copy": 0,
"oldfieldname": "fieldtype", "oldfieldname": "fieldtype",
"oldfieldtype": "Select", "oldfieldtype": "Select",
"options": "Attach\nAttach Image\nButton\nCheck\nCode\nColumn Break\nCurrency\nData\nDate\nDatetime\nDynamic Link\nFloat\nFold\nHeading\nHTML\nImage\nInt\nLink\nLong Text\nPassword\nPercent\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nText\nText Editor\nTime",
"options": "Attach\nAttach Image\nButton\nCheck\nCode\nColor\nColumn Break\nCurrency\nData\nDate\nDatetime\nDynamic Link\nFloat\nFold\nHeading\nHTML\nImage\nInt\nLink\nLong Text\nPassword\nPercent\nRead Only\nSection Break\nSelect\nSignature\nSmall Text\nTable\nText\nText Editor\nTime",
"permlevel": 0, "permlevel": 0,
"print_hide": 0, "print_hide": 0,
"print_hide_if_no_value": 0, "print_hide_if_no_value": 0,
@@ -1202,7 +1202,7 @@
"issingle": 0, "issingle": 0,
"istable": 1, "istable": 1,
"max_attachments": 0, "max_attachments": 0,
"modified": "2017-04-21 17:02:14.903382",
"modified": "2017-07-06 17:24:03.665171",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Custom", "module": "Custom",
"name": "Customize Form Field", "name": "Customize Form Field",


+ 2
- 2
frappe/desk/doctype/event/event.json View File

@@ -895,8 +895,8 @@
"issingle": 0, "issingle": 0,
"istable": 0, "istable": 0,
"max_attachments": 0, "max_attachments": 0,
"modified": "2017-05-01 15:27:39.217961",
"modified_by": "vartakashwini@gmail.com",
"modified": "2017-07-06 12:37:44.036819",
"modified_by": "Administrator",
"module": "Desk", "module": "Desk",
"name": "Event", "name": "Event",
"owner": "Administrator", "owner": "Administrator",


+ 20
- 4
frappe/desk/doctype/todo/todo.json View File

@@ -14,6 +14,7 @@
"engine": "InnoDB", "engine": "InnoDB",
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -42,6 +43,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -72,6 +74,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -83,9 +86,9 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 1,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0,
"in_standard_filter": 1,
"label": "Priority", "label": "Priority",
"length": 0, "length": 0,
"no_copy": 0, "no_copy": 0,
@@ -104,6 +107,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -131,6 +135,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -143,7 +148,7 @@
"in_filter": 0, "in_filter": 0,
"in_global_search": 0, "in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0,
"in_standard_filter": 1,
"label": "Due Date", "label": "Due Date",
"length": 0, "length": 0,
"no_copy": 0, "no_copy": 0,
@@ -161,6 +166,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -190,6 +196,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -219,6 +226,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -251,6 +259,7 @@
"width": "300px" "width": "300px"
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -279,6 +288,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -310,6 +320,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -341,6 +352,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -368,6 +380,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -399,6 +412,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -428,6 +442,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -458,6 +473,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -498,7 +514,7 @@
"issingle": 0, "issingle": 0,
"istable": 0, "istable": 0,
"max_attachments": 0, "max_attachments": 0,
"modified": "2017-03-08 14:39:02.027528",
"modified": "2017-07-13 17:44:54.369254",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Desk", "module": "Desk",
"name": "ToDo", "name": "ToDo",


+ 2
- 1
frappe/desk/form/meta.py View File

@@ -65,7 +65,7 @@ class FormMeta(Meta):
def _get_path(fname): def _get_path(fname):
return os.path.join(path, scrub(fname)) return os.path.join(path, scrub(fname))


system_country = frappe.get_system_country()
system_country = frappe.get_system_settings("country")


self._add_code(_get_path(self.name + '.js'), '__js') self._add_code(_get_path(self.name + '.js'), '__js')
if system_country: if system_country:
@@ -82,6 +82,7 @@ class FormMeta(Meta):
self.add_code_via_hook("doctype_js", "__js") self.add_code_via_hook("doctype_js", "__js")
self.add_code_via_hook("doctype_list_js", "__list_js") self.add_code_via_hook("doctype_list_js", "__list_js")
self.add_code_via_hook("doctype_tree_js", "__tree_js") self.add_code_via_hook("doctype_tree_js", "__tree_js")
self.add_code_via_hook("doctype_calendar_js", "__calendar_js")
self.add_custom_script() self.add_custom_script()
self.add_html_templates(path) self.add_html_templates(path)




+ 1
- 1
frappe/desk/page/applications/application_row.html View File

@@ -5,7 +5,7 @@
<div class="media"> <div class="media">
<div class="pull-right app-buttons"> <div class="pull-right app-buttons">
<a class="btn btn-default btn-xs" <a class="btn btn-default btn-xs"
href="{{ app.app_url }}" target="_blank">{{ __("Website") }}</a>
href="{{ app.app_url }}" target="_blank" rel="noopener noreferrer">{{ __("Website") }}</a>
{% if (app.installed) { %} {% if (app.installed) { %}
<button class="btn btn-danger btn-xs btn-remove" <button class="btn btn-danger btn-xs btn-remove"
data-title="{{ app.app_title }}" data-title="{{ app.app_title }}"


+ 1
- 1
frappe/desk/page/backups/backups.html View File

@@ -21,7 +21,7 @@
{{ f[1] }} {{ f[1] }}
</td> </td>
<td> <td>
<a href="{{ f[0] }}" target="_blank">{{ f[0] }}</a>
<a href="{{ f[0] }}" target="_blank" rel="noopener noreferrer">{{ f[0] }}</a>
</td> </td>
<td> <td>
{{ f[2] }} {{ f[2] }}


+ 7
- 0
frappe/desk/page/backups/backups.js View File

@@ -9,6 +9,13 @@ frappe.pages['backups'].on_page_load = function(wrapper) {
frappe.set_route('Form', 'System Settings'); frappe.set_route('Form', 'System Settings');
}); });


page.add_inner_button(__("Download Files Backup"), function () {
frappe.call({
method:"frappe.desk.page.backups.backups.schedule_files_backup",
args: {"user_email": frappe.session.user_email}
});
});

frappe.breadcrumbs.add("Setup"); frappe.breadcrumbs.add("Setup");


$(frappe.render_template("backups")).appendTo(page.body.addClass("no-border")); $(frappe.render_template("backups")).appendTo(page.body.addClass("no-border"));


+ 28
- 1
frappe/desk/page/backups/backups.py View File

@@ -1,6 +1,7 @@
import os import os
import frappe import frappe
from frappe.utils import get_site_path, cint
from frappe import _
from frappe.utils import get_site_path, cint, get_url
from frappe.utils.data import convert_utc_to_user_timezone from frappe.utils.data import convert_utc_to_user_timezone
import datetime import datetime


@@ -57,3 +58,29 @@ def delete_downloadable_backups():


if len(files) > backup_limit: if len(files) > backup_limit:
cleanup_old_backups(path, files, backup_limit) cleanup_old_backups(path, files, backup_limit)

@frappe.whitelist()
def schedule_files_backup(user_email):
from frappe.utils.background_jobs import enqueue, get_jobs
queued_jobs = get_jobs(site=frappe.local.site, queue="long")
method = 'frappe.desk.page.backups.backups.backup_files_and_notify_user'

if method not in queued_jobs[frappe.local.site]:
enqueue("frappe.desk.page.backups.backups.backup_files_and_notify_user", queue='long', user_email=user_email)
frappe.msgprint(_("Queued for backup. You will receive an email with the download link"))
else:
frappe.msgprint(_("Backup job is already queued. You will receive an email with the download link"))

def backup_files_and_notify_user(user_email=None):
from frappe.utils.backups import backup
backup_files = backup(with_files=True)
get_downloadable_links(backup_files)

subject = "File backup is ready"
message = frappe.render_template('frappe/templates/emails/file_backup_notification.html', backup_files, is_path=True)
frappe.sendmail(recipients=[user_email], subject=subject, message=message)

def get_downloadable_links(backup_files):
for key in ['backup_path_files', 'backup_path_private_files']:
path = backup_files[key]
backup_files[key] = get_url('/'.join(path.split('/')[-2:]))

+ 112
- 7
frappe/desk/page/setup_wizard/setup_wizard.css View File

@@ -1,3 +1,26 @@
#page-setup-wizard {
margin-top: 30px;
}

.setup-wizard-brand {
margin: 30px;
text-align: center;
display: flex;
justify-content: center;
align-items: center
}

.setup-wizard-brand .brand-icon {
width: 36px;
height: 36px;
}

.setup-wizard-brand .brand-name {
font-size: 20px;
margin-left: 8px;
color: #36414C;
}

.setup-wizard-slide { .setup-wizard-slide {
padding-left: 0px; padding-left: 0px;
padding-right: 0px; padding-right: 0px;
@@ -14,22 +37,60 @@
} }


.setup-wizard-slide .lead { .setup-wizard-slide .lead {
margin-bottom: 10px;
margin: 30px;
color: #777777;
text-align: center;
font-size: 24px;
}

.setup-wizard-slide .col-sm-12 {
padding: 0px;
}

.setup-wizard-slide .section-body .col-sm-6:first-child {
padding-left: 0px;
}

.setup-wizard-slide .section-body .col-sm-6:last-child {
padding-right: 0px;
}

.setup-wizard-slide .form-control {
font-weight: 500; font-weight: 500;
} }


.setup-wizard-slide .has-error .control-label {
color: #ffa00a;
}

.setup-wizard-slide .has-error .form-control{
border-color: #ffa00a;
}

.setup-wizard-slide .form-control.bold {
background-color: #fff;
}

.setup-wizard-slide.with-form { .setup-wizard-slide.with-form {
margin: 40px auto;
margin: 30px auto;
padding: 10px 50px;
border: 1px solid #d1d8dd; border: 1px solid #d1d8dd;
box-shadow: 0px 3px 5px rgba(0, 0, 0, 0.1); box-shadow: 0px 3px 5px rgba(0, 0, 0, 0.1);
} }


.setup-wizard-slide .footer { .setup-wizard-slide .footer {
padding: 30px;
padding: 30px 0px;
}

.setup-wizard-slide a.next-btn.disabled,
.setup-wizard-slide a.complete-btn.disabled {
background-color: #b1bdca;
color: #fff;
border-color: #b1bdca;
} }


.setup-wizard-progress { .setup-wizard-progress {
padding: 15px;
padding: 15px;
} }


.setup-wizard-slide .fa-fw { .setup-wizard-slide .fa-fw {
@@ -50,16 +111,28 @@
} }


.setup-wizard-slide .frappe-control[data-fieldtype="Attach Image"] { .setup-wizard-slide .frappe-control[data-fieldtype="Attach Image"] {
width: 140px;
height: 180px; /*depends on presence of heading*/
text-align: center; text-align: center;
margin-left: calc((100% - 140px)/2);
}

.setup-wizard-slide .frappe-control[data-fieldtype="Attach Image"] .form-group,
.setup-wizard-slide .frappe-control[data-fieldtype="Attach Image"] .clearfix {
display: none;
} }


.setup-wizard-slide .missing-image, .setup-wizard-slide .missing-image,
.setup-wizard-slide .attach-image-display { .setup-wizard-slide .attach-image-display {
display: block; display: block;
position: relative; position: relative;
left: 50%;
transform: translate(-50%, 0);
-webkit-transform: translate(-50%, 0);
border-radius: 4px;
}

.setup-wizard-slide .missing-image {
border: 1px solid #d1d8dd;
border-radius: 6px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
} }


.setup-wizard-slide .missing-image .octicon { .setup-wizard-slide .missing-image .octicon {
@@ -69,6 +142,38 @@
-webkit-transform: translate(0px, -50%); -webkit-transform: translate(0px, -50%);
} }



.setup-wizard-slide .img-container {
height: 100%;
width: 100%;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
border: 1px solid #d1d8dd;
border-radius: 6px;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075);
}

.setup-wizard-slide .img-overlay {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
width: 100%;
height: 100%;
color: #777777;
background-color: rgba(255, 255, 255, 0.7);
opacity: 0;
}

.setup-wizard-slide .img-overlay:hover {
opacity: 1;
cursor: pointer;
}


.setup-wizard-message-image { .setup-wizard-message-image {
margin: 15px auto; margin: 15px auto;
} }

+ 352
- 197
frappe/desk/page/setup_wizard/setup_wizard.js View File

@@ -1,22 +1,25 @@
frappe.provide("frappe.wiz"); frappe.provide("frappe.wiz");
frappe.provide("frappe.wiz.events");
frappe.provide("frappe.setup.events");


frappe.wiz = {
frappe.setup = {
slides: [], slides: [],
events: {}, events: {},
data: {},
utils: {},

remove_app_slides: [], remove_app_slides: [],
on: function(event, fn) { on: function(event, fn) {
if(!frappe.wiz.events[event]) {
frappe.wiz.events[event] = [];
if(!frappe.setup.events[event]) {
frappe.setup.events[event] = [];
} }
frappe.wiz.events[event].push(fn);
frappe.setup.events[event].push(fn);
}, },
add_slide: function(slide) { add_slide: function(slide) {
frappe.wiz.slides.push(slide);
frappe.setup.slides.push(slide);
}, },


run_event: function(event) { run_event: function(event) {
$.each(frappe.wiz.events[event] || [], function(i, fn) {
$.each(frappe.setup.events[event] || [], function(i, fn) {
fn(); fn();
}); });
} }
@@ -25,21 +28,21 @@ frappe.wiz = {
frappe.pages['setup-wizard'].on_page_load = function(wrapper) { frappe.pages['setup-wizard'].on_page_load = function(wrapper) {
// setup page ui // setup page ui
$(".navbar:first").toggle(false); $(".navbar:first").toggle(false);
$("body").css({"padding-top":"30px"});


var requires = ["/assets/frappe/css/animate.min.css"].concat(frappe.boot.setup_wizard_requires || []); var requires = ["/assets/frappe/css/animate.min.css"].concat(frappe.boot.setup_wizard_requires || []);


frappe.require(requires, function() { frappe.require(requires, function() {
frappe.wiz.run_event("before_load");
frappe.setup.run_event("before_load");

var wizard_settings = { var wizard_settings = {
page_name: "setup-wizard", page_name: "setup-wizard",
parent: wrapper, parent: wrapper,
slides: frappe.wiz.slides,
slides: frappe.setup.slides,
title: __("Welcome") title: __("Welcome")
} }


frappe.wizard = new frappe.wiz.Wizard(wizard_settings);
frappe.wiz.run_event("after_load");
frappe.wizard = new frappe.setup.Wizard(wizard_settings);
frappe.setup.run_event("after_load");


// frappe.wizard.values = test_values_edu; // frappe.wizard.values = test_values_edu;


@@ -56,7 +59,7 @@ frappe.pages['setup-wizard'].on_page_show = function(wrapper) {
} }
} }


frappe.wiz.Wizard = Class.extend({
frappe.setup.Wizard = Class.extend({
init: function(opts) { init: function(opts) {
$.extend(this, opts); $.extend(this, opts);
this.make(); this.make();
@@ -75,6 +78,7 @@ frappe.wiz.Wizard = Class.extend({
</div>', {html:html})) </div>', {html:html}))
}, },
show_working: function() { show_working: function() {
$('header').find('.setup-wizard-brand').hide();
this.hide_current_slide(); this.hide_current_slide();
frappe.set_route(this.page_name); frappe.set_route(this.page_name);
this.current_slide = {"$wrapper": this.get_message(this.working_html()).appendTo(this.parent)}; this.current_slide = {"$wrapper": this.get_message(this.working_html()).appendTo(this.parent)};
@@ -96,7 +100,7 @@ frappe.wiz.Wizard = Class.extend({
this.update_values(); this.update_values();


if(!this.slide_dict[id]) { if(!this.slide_dict[id]) {
this.slide_dict[id] = new frappe.wiz.WizardSlide($.extend(this.slides[id], {wiz:this, id:id}));
this.slide_dict[id] = new frappe.setup.WizardSlide($.extend(this.slides[id], {wiz:this, id:id}));
this.slide_dict[id].make(); this.slide_dict[id].make();
} }


@@ -147,8 +151,8 @@ frappe.wiz.Wizard = Class.extend({
args: {args: this.values}, args: {args: this.values},
callback: function(r) { callback: function(r) {
me.show_complete(); me.show_complete();
if(frappe.wiz.welcome_page) {
localStorage.setItem("session_last_route", frappe.wiz.welcome_page);
if(frappe.setup.welcome_page) {
localStorage.setItem("session_last_route", frappe.setup.welcome_page);
} }
setTimeout(function() { setTimeout(function() {
window.location = "/desk"; window.location = "/desk";
@@ -181,26 +185,27 @@ frappe.wiz.Wizard = Class.extend({


this.update_values(); this.update_values();


frappe.wiz.slides = [];
frappe.wiz.run_event("before_load");
frappe.setup.slides = [];
frappe.setup.run_event("before_load");


// remove slides listed in remove_app_slides // remove slides listed in remove_app_slides
var new_slides = []; var new_slides = [];
frappe.wiz.slides.forEach(function(slide) {
if(frappe.wiz.domain) {
frappe.setup.slides.forEach(function(slide) {
if(frappe.setup.domain) {
var domains = slide.domains; var domains = slide.domains;
if (domains.indexOf('all') !== -1 || if (domains.indexOf('all') !== -1 ||
domains.indexOf(frappe.wiz.domain.toLowerCase()) !== -1) {
domains.indexOf(frappe.setup.domain.toLowerCase()) !== -1) {
new_slides.push(slide); new_slides.push(slide);
} }
} else { } else {
new_slides.push(slide); new_slides.push(slide);
} }
}) })
frappe.wiz.slides = new_slides;


this.slides = frappe.wiz.slides;
frappe.wiz.run_event("after_load");
frappe.setup.slides = new_slides;

this.slides = frappe.setup.slides;
frappe.setup.run_event("after_load");


// re-render all slides // re-render all slides
this.slide_dict = {}; this.slide_dict = {};
@@ -213,7 +218,7 @@ frappe.wiz.Wizard = Class.extend({
} }
}); });


frappe.wiz.WizardSlide = Class.extend({
frappe.setup.WizardSlide = Class.extend({
init: function(opts) { init: function(opts) {
$.extend(this, opts); $.extend(this, opts);
this.$wrapper = $('<div class="slide-wrapper hidden"></div>') this.$wrapper = $('<div class="slide-wrapper hidden"></div>')
@@ -224,6 +229,24 @@ frappe.wiz.WizardSlide = Class.extend({
var me = this; var me = this;
if(this.$body) this.$body.remove(); if(this.$body) this.$body.remove();


var fields = JSON.parse(JSON.stringify(this.fields));

if(this.add_more) {
this.count = 1;
fields = fields.map((field, i) => {
if(field.fieldname) {
field.fieldname += '_1';
}
if(i === 1 && this.mandatory_entry) {
field.reqd = 1;
}
if(!field.static) {
if(field.label) field.label += ' 1';
}
return field;
});
}

if(this.before_load) { if(this.before_load) {
this.before_load(this); this.before_load(this);
} }
@@ -234,7 +257,6 @@ frappe.wiz.WizardSlide = Class.extend({
main_title:__(this.wiz.title), main_title:__(this.wiz.title),
step: this.id + 1, step: this.id + 1,
name: this.name, name: this.name,
css_class: this.css_class || "",
slides_count: this.wiz.slides.length slides_count: this.wiz.slides.length
})).appendTo(this.$wrapper); })).appendTo(this.$wrapper);


@@ -242,7 +264,7 @@ frappe.wiz.WizardSlide = Class.extend({


if(this.fields) { if(this.fields) {
this.form = new frappe.ui.FieldGroup({ this.form = new frappe.ui.FieldGroup({
fields: this.fields,
fields: fields,
body: this.body, body: this.body,
no_submit_on_enter: true no_submit_on_enter: true
}); });
@@ -251,18 +273,36 @@ frappe.wiz.WizardSlide = Class.extend({
$(this.body).html(this.html); $(this.body).html(this.html);
} }


this.set_reqd_fields();
this.set_init_values(); this.set_init_values();
this.make_prev_next_buttons(); this.make_prev_next_buttons();
if(this.add_more) this.bind_more_button();

var $primary_btn = this.$next ? this.$next : this.$complete;

this.bind_fields_to_next($primary_btn);


if(this.onload) { if(this.onload) {
this.onload(this); this.onload(this);
} }
this.focus_first_input();
this.set_reqd_fields();
this.bind_fields_to_next($primary_btn);


this.reset_next($primary_btn);
this.focus_first_input();
},
set_reqd_fields: function() {
var dict = this.form.fields_dict;
this.reqd_fields = [];
Object.keys(dict).map(key => {
if(dict[key].df.reqd) {
this.reqd_fields.push(dict[key]);
}
});
}, },
set_init_values: function() { set_init_values: function() {
var me = this; var me = this;
// set values from frappe.wiz.values
// set values from frappe.setup.values
if(frappe.wizard.values && this.fields) { if(frappe.wizard.values && this.fields) {
this.fields.forEach(function(f) { this.fields.forEach(function(f) {
var value = frappe.wizard.values[f.fieldname]; var value = frappe.wizard.values[f.fieldname];
@@ -284,6 +324,25 @@ frappe.wiz.WizardSlide = Class.extend({
return true; return true;
}, },


bind_more_button: function() {
this.$more = this.$body.find('.more-btn');
this.$more.removeClass('hide')
.on('click', () => {
this.count++;
var fields = JSON.parse(JSON.stringify(this.fields));
this.form.add_fields(fields.map(field => {
if(field.fieldname) field.fieldname += '_' + this.count;
if(!field.static) {
if(field.label) field.label += ' ' + this.count;
}
return field;
}));
if(this.count === this.max_count) {
this.$more.addClass('hide');
}
});
},

make_prev_next_buttons: function() { make_prev_next_buttons: function() {
var me = this; var me = this;


@@ -311,7 +370,7 @@ frappe.wiz.WizardSlide = Class.extend({
.click(this.next_or_complete.bind(this)); .click(this.next_or_complete.bind(this));
} }


//setup mousefree navigation
// setup mousefree navigation
this.$body.on('keypress', function(e) { this.$body.on('keypress', function(e) {
if(e.which === 13) { if(e.which === 13) {
var $target = $(e.target); var $target = $(e.target);
@@ -326,6 +385,14 @@ frappe.wiz.WizardSlide = Class.extend({
} }
}); });
}, },
bind_fields_to_next: function($primary_btn) {
var me = this;
this.reqd_fields.map((field) => {
field.$wrapper.on('change input', () => {
me.reset_next($primary_btn);
});
});
},
next_or_complete: function() { next_or_complete: function() {
if(this.set_values()) { if(this.set_values()) {
if(this.id+1 < this.wiz.slides.length) { if(this.id+1 < this.wiz.slides.length) {
@@ -335,6 +402,17 @@ frappe.wiz.WizardSlide = Class.extend({
} }
} }
}, },
reset_next: function($primary_btn) {
var empty_fields = this.reqd_fields.filter((field) => {
return !field.get_value();
})

if(empty_fields.length) {
$primary_btn.addClass('disabled');
} else {
$primary_btn.removeClass('disabled');
}
},
focus_first_input: function() { focus_first_input: function() {
setTimeout(function() { setTimeout(function() {
this.$body.find('.form-control').first().focus(); this.$body.find('.form-control').first().focus();
@@ -360,233 +438,310 @@ frappe.wiz.WizardSlide = Class.extend({
}, },
}); });


function load_frappe_slides() {
// language selection
frappe.wiz.welcome = {
var frappe_slides = [
{
// Welcome (language) slide
name: "welcome", name: "welcome",
domains: ["all"], domains: ["all"],
title: __("Welcome"),
title: __("Hello!"),
icon: "fa fa-world", icon: "fa fa-world",
help: __("Let's prepare the system for first use."), help: __("Let's prepare the system for first use."),


fields: [ fields: [
{ fieldname: "language", label: __("Select Your Language"), reqd:1,
fieldtype: "Select", "default": "english" },
{ fieldname: "language", label: __("Your Language"),
fieldtype: "Select", reqd: 1}
], ],


onload: function(slide) { onload: function(slide) {
if (!frappe.wiz.welcome.data) {
frappe.wiz.welcome.load_languages(slide);
if (frappe.setup.data.lang) {
this.setup_fields(slide);
} else { } else {
frappe.wiz.welcome.setup_fields(slide);
utils.load_languages(slide, this.setup_fields);
} }
}, },


css_class: "single-column",
load_languages: function(slide) {
frappe.call({
method: "frappe.desk.page.setup_wizard.setup_wizard.load_languages",
freeze: true,
callback: function(r) {
frappe.wiz.welcome.data = r.message;
frappe.wiz.welcome.setup_fields(slide);

var language_field = slide.get_field("language");
language_field.set_input(frappe.wiz.welcome.data.default_language || "english");

if (!frappe.wiz._from_load_messages) {
language_field.$input.trigger("change");
}

delete frappe.wiz._from_load_messages;

moment.locale("en");
}
});
},

setup_fields: function(slide) { setup_fields: function(slide) {
var select = slide.get_field("language");
select.df.options = frappe.wiz.welcome.data.languages;
select.refresh();
frappe.wiz.welcome.bind_events(slide);
utils.setup_language_field(slide);
utils.bind_language_events(slide);
}, },

bind_events: function(slide) {
slide.get_input("language").unbind("change").on("change", function() {
var lang = $(this).val() || "english";
frappe._messages = {};
frappe.call({
method: "frappe.desk.page.setup_wizard.setup_wizard.load_messages",
freeze: true,
args: {
language: lang
},
callback: function(r) {
frappe.wiz._from_load_messages = true;
frappe.wizard.refresh_slides();
}
});
});
}
}, },


// region selection
frappe.wiz.region = {
{
// Region slide
name: 'region',
domains: ["all"], domains: ["all"],
title: __("Region"),
title: __("Select Your Region"),
icon: "fa fa-flag", icon: "fa fa-flag",
help: __("Select your Country, Time Zone and Currency"), help: __("Select your Country, Time Zone and Currency"),
fields: [ fields: [
{ fieldname: "country", label: __("Country"), reqd:1,
{ fieldname: "country", label: __("Your Country"), reqd:1,
fieldtype: "Select" }, fieldtype: "Select" },
{ fieldtype: "Section Break" },
{ fieldname: "timezone", label: __("Time Zone"), reqd:1, { fieldname: "timezone", label: __("Time Zone"), reqd:1,
fieldtype: "Select" }, fieldtype: "Select" },
{ fieldtype: "Column Break" },
{ fieldname: "currency", label: __("Currency"), reqd:1, { fieldname: "currency", label: __("Currency"), reqd:1,
fieldtype: "Select" },
fieldtype: "Select" }
], ],


onload: function(slide) { onload: function(slide) {
var _setup = function() {
frappe.wiz.region.setup_fields(slide);
frappe.wiz.region.bind_events(slide);
};

if(frappe.wiz.regional_data) {
_setup();
if(frappe.setup.data.regional_data) {
this.setup_fields(slide);
} else { } else {
frappe.call({
method:"frappe.geo.country_info.get_country_timezone_info",
callback: function(data) {
frappe.wiz.regional_data = data.message;
_setup();
}
});
utils.load_regional_data(slide, this.setup_fields);
} }
}, },
css_class: "single-column",

setup_fields: function(slide) { setup_fields: function(slide) {
var data = frappe.wiz.regional_data;
utils.setup_region_fields(slide);
utils.bind_region_events(slide);
}
},


slide.get_input("country").empty()
.add_options([""].concat(Object.keys(data.country_info).sort()));
{
// Profile slide
name: 'user',
domains: ["all"],
title: __("The First User: You"),
icon: "fa fa-user",
fields: [
{ "fieldtype":"Attach Image", "fieldname":"attach_user_image",
label: __("Attach Your Picture"), is_private: 0},
{ "fieldname": "full_name", "label": __("Full Name"), "fieldtype": "Data",
reqd:1},
{ "fieldname": "email", "label": __("Email Address") + ' (' + __("Will be your login ID") + ')',
"fieldtype": "Data", "options":"Email"},
{ "fieldname": "password", "label": __("Password"), "fieldtype": "Password" }
],
help: __('The first user will become the System Manager (you can change this later).'),
onload: function(slide) {
if(frappe.session.user!=="Administrator") {
slide.form.fields_dict.email.$wrapper.toggle(false);
slide.form.fields_dict.password.$wrapper.toggle(false);


// remove password field
delete slide.form.fields_dict.password;


slide.get_input("currency").empty()
.add_options(frappe.utils.unique([""].concat($.map(data.country_info,
function(opts, country) { return opts.currency; }))).sort());
if(frappe.boot.user.first_name || frappe.boot.user.last_name) {
slide.form.fields_dict.full_name.set_input(
[frappe.boot.user.first_name, frappe.boot.user.last_name].join(' ').trim());
}


slide.get_input("timezone").empty()
.add_options([""].concat(data.all_timezones));
var user_image = frappe.get_cookie("user_image");
var $attach_user_image = slide.form.fields_dict.attach_user_image.$wrapper;


// set values if present
if(frappe.wizard.values.country) {
slide.get_field("country").set_input(frappe.wizard.values.country);
} else if (data.default_country) {
slide.get_field("country").set_input(data.default_country);
}
if(user_image) {
$attach_user_image.find(".missing-image").toggle(false);
$attach_user_image.find("img").attr("src", decodeURIComponent(user_image));
$attach_user_image.find(".img-container").toggle(true);
}
delete slide.form.fields_dict.email;


if(frappe.wizard.values.currency) {
slide.get_field("currency").set_input(frappe.wizard.values.currency);
}
} else {
slide.form.fields_dict.email.df.reqd = 1;
slide.form.fields_dict.email.refresh();
slide.form.fields_dict.password.df.reqd = 1;
slide.form.fields_dict.password.refresh();


if(frappe.wizard.values.timezone) {
slide.get_field("timezone").set_input(frappe.wizard.values.timezone);
utils.load_user_details(slide, this.setup_fields);
} }
},


setup_fields: function(slide) {
if(frappe.setup.data.full_name) {
slide.form.fields_dict.full_name.set_input(frappe.setup.data.full_name);
}
if(frappe.setup.data.email) {
let email = frappe.setup.data.email;
slide.form.fields_dict.email.set_input(email);
if (frappe.get_gravatar(email, 200)) {
var $attach_user_image = slide.form.fields_dict.attach_user_image.$wrapper;
$attach_user_image.find(".missing-image").toggle(false);
$attach_user_image.find("img").attr("src", frappe.get_gravatar(email, 200));
$attach_user_image.find(".img-container").toggle(true);
}
}
}, },
},
];


bind_events: function(slide) {
slide.get_input("country").on("change", function() {
var country = slide.get_input("country").val();
var $timezone = slide.get_input("timezone");
var data = frappe.wiz.regional_data;
var utils = {
load_languages: function(slide, callback) {
frappe.call({
method: "frappe.desk.page.setup_wizard.setup_wizard.load_languages",
freeze: true,
callback: function(r) {
frappe.setup.data.lang = r.message;
callback(slide);


$timezone.empty();
var language_field = slide.get_field("language");


// add country specific timezones first
if(country) {
var timezone_list = data.country_info[country].timezones || [];
$timezone.add_options(timezone_list.sort());
slide.get_field("currency").set_input(data.country_info[country].currency);
slide.get_field("currency").$input.trigger("change");
language_field.set_input(frappe.setup.data.default_language || "English");

if (!frappe.setup._from_load_messages) {
language_field.$input.trigger("change");
} }
delete frappe.setup._from_load_messages;
moment.locale("en");
}
});
},

load_regional_data: function(slide, callback) {
frappe.call({
method:"frappe.geo.country_info.get_country_timezone_info",
callback: function(data) {
frappe.setup.data.regional_data = data.message;
callback(slide);
}
});
},

load_user_details: function(slide, callback) {
frappe.call({
method: "frappe.desk.page.setup_wizard.setup_wizard.load_user_details",
freeze: true,
callback: function(r) {
frappe.setup.data.full_name = r.message.full_name;
frappe.setup.data.email = r.message.email;
callback(slide);
}
})
},


// add all timezones at the end, so that user has the option to change it to any timezone
$timezone.add_options([""].concat(data.all_timezones));
setup_language_field: function(slide) {
var language_field = slide.get_field("language");
language_field.df.options = frappe.setup.data.lang.languages;
language_field.refresh();
},


slide.get_field("timezone").set_input($timezone.val());
setup_region_fields: function(slide) {
/*
Set a slide's country, timezone and currency fields
*/
var data = frappe.setup.data.regional_data;


// temporarily set date format
frappe.boot.sysdefaults.date_format = (data.country_info[country].date_format
|| "dd-mm-yyyy");
});
var country_field = slide.get_field('country');


slide.get_input("currency").on("change", function() {
var currency = slide.get_input("currency").val();
if (!currency) return;
frappe.model.with_doc("Currency", currency, function() {
frappe.provide("locals.:Currency." + currency);
var currency_doc = frappe.model.get_doc("Currency", currency);
var number_format = currency_doc.number_format;
if (number_format==="#.###") {
number_format = "#.###,##";
} else if (number_format==="#,###") {
number_format = "#,###.##"
}
slide.get_input("country").empty()
.add_options([""].concat(Object.keys(data.country_info).sort()));


frappe.boot.sysdefaults.number_format = number_format;
locals[":Currency"][currency] = $.extend({}, currency_doc);
});
});
slide.get_input("currency").empty()
.add_options(frappe.utils.unique([""].concat($.map(data.country_info,
function(opts, country) { return opts.currency; }))).sort());

slide.get_input("timezone").empty()
.add_options([""].concat(data.all_timezones));

// set values if present
if(frappe.wizard.values.country) {
country_field.set_input(frappe.wizard.values.country);
} else if (data.default_country) {
country_field.set_input(data.default_country);
} }
},


if(frappe.wizard.values.currency) {
slide.get_field("currency").set_input(frappe.wizard.values.currency);
}


frappe.wiz.user = {
domains: ["all"],
title: __("The First User: You"),
icon: "fa fa-user",
fields: [
{"fieldname": "full_name", "label": __("Full Name"), "fieldtype": "Data",
reqd:1},
{"fieldname": "email", "label": __("Email Address"), "fieldtype": "Data",
reqd:1, "description": __("Login id"), "options":"Email"},
{"fieldname": "password", "label": __("Password"), "fieldtype": "Password",
reqd:1},
{fieldtype:"Attach Image", fieldname:"attach_user",
label: __("Attach Your Picture"), is_private: 0},
],
help: __('The first user will become the System Manager (you can change this later).'),
onload: function(slide) {
if(frappe.session.user!=="Administrator") {
slide.form.fields_dict.password.$wrapper.toggle(false);
slide.form.fields_dict.email.$wrapper.toggle(false);
if(frappe.boot.user.first_name || frappe.boot.user.last_name) {
slide.form.fields_dict.full_name.set_input(
[frappe.boot.user.first_name, frappe.boot.user.last_name].join(' ').trim());
if(frappe.wizard.values.timezone) {
slide.get_field("timezone").set_input(frappe.wizard.values.timezone);
}

country_field.df.description = 'fetching country...';
country_field.set_description();

// get location from IP (unreliable)
frappe.call({
method:"frappe.desk.page.setup_wizard.setup_wizard.load_country",
callback: function(r) {
if(r.message) {
slide.get_field("country").set_input(r.message);
slide.get_input("country").trigger('change');
} }
country_field.df.description = '';
country_field.set_description();
}
});
},


var user_image = frappe.get_cookie("user_image");
if(user_image) {
var $attach_user = slide.form.fields_dict.attach_user.$wrapper;
$attach_user.find(".missing-image").toggle(false);
$attach_user.find("img").attr("src", decodeURIComponent(user_image)).toggle(true);
bind_language_events: function(slide) {
slide.get_input("language").unbind("change").on("change", function() {
var lang = $(this).val() || "English";
frappe._messages = {};
frappe.call({
method: "frappe.desk.page.setup_wizard.setup_wizard.load_messages",
freeze: true,
args: {
language: lang
},
callback: function(r) {
frappe.setup._from_load_messages = true;
frappe.wizard.refresh_slides();
} }
});
});
},


delete slide.form.fields_dict.email;
delete slide.form.fields_dict.password;
bind_region_events: function(slide) {
/*
Bind a slide's country, timezone and currency fields
*/
slide.get_input("country").on("change", function() {
var country = slide.get_input("country").val();
var $timezone = slide.get_input("timezone");
var data = frappe.setup.data.regional_data;

$timezone.empty();

// add country specific timezones first
if(country) {
var timezone_list = data.country_info[country].timezones || [];
$timezone.add_options(timezone_list.sort());
slide.get_field("currency").set_input(data.country_info[country].currency);
slide.get_field("currency").$input.trigger("change");
} }
},
css_class: "single-column"
};

// add all timezones at the end, so that user has the option to change it to any timezone
$timezone.add_options([""].concat(data.all_timezones));

slide.get_field("timezone").set_input($timezone.val());

// temporarily set date format
frappe.boot.sysdefaults.date_format = (data.country_info[country].date_format
|| "dd-mm-yyyy");
});

slide.get_input("currency").on("change", function() {
var currency = slide.get_input("currency").val();
if (!currency) return;
frappe.model.with_doc("Currency", currency, function() {
frappe.provide("locals.:Currency." + currency);
var currency_doc = frappe.model.get_doc("Currency", currency);
var number_format = currency_doc.number_format;
if (number_format==="#.###") {
number_format = "#.###,##";
} else if (number_format==="#,###") {
number_format = "#,###.##"
}

frappe.boot.sysdefaults.number_format = number_format;
locals[":Currency"][currency] = $.extend({}, currency_doc);
});
});
},

} }


frappe.wiz.on("before_load", function() {
load_frappe_slides();
frappe.setup.on("before_load", function() {
// load slides
frappe_slides.map(frappe.setup.add_slide);


// add welcome slide
frappe.wiz.add_slide(frappe.wiz.welcome);
frappe.wiz.add_slide(frappe.wiz.region);
frappe.wiz.add_slide(frappe.wiz.user);
// set header image
let $icon = $('header .setup-wizard-brand');
if($icon.length === 0) {
$('header').append(`<div class="setup-wizard-brand"">
<img src="/assets/frappe/images/frappe-bird-grey.svg"
class="brand-icon frappe-icon" style="width:36px;"></div>`);
}
}); });

+ 17
- 1
frappe/desk/page/setup_wizard/setup_wizard.py View File

@@ -179,11 +179,27 @@ def load_messages(language):


@frappe.whitelist() @frappe.whitelist()
def load_languages(): def load_languages():
language_codes = frappe.db.sql('select language_code, language_name from tabLanguage order by name', as_dict=True)
codes_to_names = {}
for d in language_codes:
codes_to_names[d.language_code] = d.language_name
return { return {
"default_language": frappe.db.get_value('Language', frappe.local.lang, 'language_name') or frappe.local.lang, "default_language": frappe.db.get_value('Language', frappe.local.lang, 'language_name') or frappe.local.lang,
"languages": sorted(frappe.db.sql_list('select language_name from tabLanguage order by name'))
"languages": sorted(frappe.db.sql_list('select language_name from tabLanguage order by name')),
"codes_to_names": codes_to_names
} }


@frappe.whitelist()
def load_country():
from frappe.sessions import get_geo_ip_country
return get_geo_ip_country(frappe.local.request_ip) if frappe.local.request_ip else None

@frappe.whitelist()
def load_user_details():
return {
"full_name": frappe.cache().hget("full_name", "signup"),
"email": frappe.cache().hget("email", "signup")
}


def prettify_args(args): def prettify_args(args):
# remove attachments # remove attachments


+ 8
- 4
frappe/desk/page/setup_wizard/setup_wizard_page.html View File

@@ -1,14 +1,18 @@
<div class="container setup-wizard-slide {%= css_class %} with-form" data-slide-name="{%= name %}">
<div class="container setup-wizard-slide single-column with-form" data-slide-name="{%= name %}">
<div class="text-center setup-wizard-progress text-extra-muted"> <div class="text-center setup-wizard-progress text-extra-muted">
{% for (var i=0; i < slides_count; i++) { %} {% for (var i=0; i < slides_count; i++) { %}
<i class="fa fa-fw fa-circle{% if (i+1<=step) { %} active {% } %}"></i>
<!--dev_mode: link progress bubbles-->
<!--<a href="http://erpnext.domainify:8000/desk#setup-wizard/{%= i %}">-->
<i class="fa fa-fw fa-circle{% if (i+1<=step) { %} active {% } %}"></i>
<!--</a>-->
{% } %} {% } %}
</div> </div>
<p class="text-center lead">{%= title %}</p>
<p class="lead">{%= title %}</p>
<div class="row"> <div class="row">
<div class="col-sm-12">
<div class="setup-wizard-body col-sm-12">
<!-- {% if (help) { %} <p class="text-center">{%= help %}</p> {% } %} --> <!-- {% if (help) { %} <p class="text-center">{%= help %}</p> {% } %} -->
<div class="form"></div> <div class="form"></div>
<a class="more-btn hide btn btn-default btn-sm" style="margin-left: 41%;">{%= __("Add More") %}</a>
</div> </div>
</div> </div>
<div class="footer text-right"> <div class="footer text-right">


+ 1
- 1
frappe/desk/query_report.py View File

@@ -159,7 +159,7 @@ def export_query():
elif not row: elif not row:
result.append([]) result.append([])
else: else:
result = result + data.result
result = result + [d for i,d in enumerate(data.result) if (i+1 in visible_idx)]


from frappe.utils.xlsxutils import make_xlsx from frappe.utils.xlsxutils import make_xlsx
xlsx_file = make_xlsx(result, "Query Report") xlsx_file = make_xlsx(result, "Query Report")


BIN
frappe/docs/assets/img/app-development/test-runner.png View File

Before After
Width: 1972  |  Height: 1116  |  Size: 258 KiB

+ 0
- 0
frappe/docs/user/en/guides/automated-testing/__init__.py View File


+ 7
- 0
frappe/docs/user/en/guides/automated-testing/index.md View File

@@ -0,0 +1,7 @@
# Automated Testing

Frappé Provides you a test framework to write and execute tests that can be run directly on a Continuous Integration Tool like Travis

You can write server-side unit tests or UI tests

{index}

+ 3
- 0
frappe/docs/user/en/guides/automated-testing/index.txt View File

@@ -0,0 +1,3 @@
unit-testing
integration-testing
qunit-testing

+ 49
- 0
frappe/docs/user/en/guides/automated-testing/integration-testing.md View File

@@ -0,0 +1,49 @@
# UI Integration Testing

You can write integration tests using the Selenium Driver. `frappe.utils.selenium_driver` gives you a friendly API to write selenium based tests

To write integration tests, create a standard test case by creating a python file starting with `test_`

All integration tests will be run at the end of the unittests.

### Example

Here is an example of an integration test to check insertion of a To Do

from __future__ import print_function
from frappe.utils.selenium_testdriver import TestDriver
import unittest
import time

class TestToDo(unittest.TestCase):
def setUp(self):
self.driver = TestDriver()

def test_todo(self):
self.driver.login()

# list view
self.driver.set_route('List', 'ToDo')

time.sleep(2)

# new
self.driver.click_primary_action()

time.sleep(2)

# set input
self.driver.set_text_editor('description', 'hello')

# save
self.driver.click_modal_primary_action()

time.sleep(2)

self.assertTrue(self.driver.get_visible_element('.result-list')
.find_element_by_css_selector('.list-item')
.find_element_by_css_selector('.list-id').text=='hello')

def tearDown(self):
self.driver.close()


+ 70
- 0
frappe/docs/user/en/guides/automated-testing/qunit-testing.md View File

@@ -0,0 +1,70 @@
# UI Testing with Frappe API

You can either write integration tests, or directly write tests in Javascript using [QUnit](http://api.qunitjs.com/)

QUnit helps you write UI tests using the UQuit framework and native frappe API. As you might have guessed, this is a much faster way of writing tests.

### Test Runner

To write QUnit based tests, add your tests in the `tests/ui` folder of your application. Your test files must begin with `test_` and end with `.js` extension.

To run your files, you can use the **Test Runner**. The **Test Runner** gives a user interface to load all your QUnit tests and run them in the browser.

In the CI, all QUnit tests are run by the **Test Runner** using `frappe/tests/test_test_runner.py`

<img src="{{docs_base_url}}/assets/img/app-development/test-runner.png" class="screenshot">

### 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_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

Promises are a great way to write test-friendly code. If your method calls an aysnchronous call (ajax), then you should return an `Promise` object. While writing tests, if you encounter a function that does not return a `Promise` object, you should update the code to return a `Promise` object.

frappe/docs/user/en/guides/basics/writing-tests.md → frappe/docs/user/en/guides/automated-testing/unit-testing.md View File

@@ -1,4 +1,4 @@
# Writing Tests Guide
# Unit Testing


## 1.Introduction ## 1.Introduction


@@ -16,12 +16,12 @@ Frappe provides some basic tooling to quickly write automated tests. There are s
This function will build all the test dependencies and run your tests. This function will build all the test dependencies and run your tests.
You should run tests from "frappe_bench" folder. Without options all tests will be run. You should run tests from "frappe_bench" folder. Without options all tests will be run.


bench run-tests
bench run-tests


If you need more information about test execution - you can use verbose log level for bench. If you need more information about test execution - you can use verbose log level for bench.


bench --verbose run-tests bench --verbose run-tests
### Options: ### Options:


--app <AppName> --app <AppName>
@@ -30,9 +30,9 @@ If you need more information about test execution - you can use verbose log leve
--module <Module> (Run a particular module that has tests) --module <Module> (Run a particular module that has tests)
--profile (Runs a Python profiler on the test) --profile (Runs a Python profiler on the test)
--junit-xml-output<PathToXML> (The command provides test results in the standard XUnit XML format) --junit-xml-output<PathToXML> (The command provides test results in the standard XUnit XML format)
#### 2.1. Example for app: #### 2.1. Example for app:
All applications are located in folder: "~/frappe-bench/apps".
All applications are located in folder: "~/frappe-bench/apps".
We can run tests for each application. We can run tests for each application.


- frappe-bench/apps/erpnext/ - frappe-bench/apps/erpnext/
@@ -50,7 +50,7 @@ We can run tests for each application.
. .
---------------------------------------------------------------------- ----------------------------------------------------------------------
Ran 1 test in 0.008s Ran 1 test in 0.008s
OK OK


#### 2.3. Example for test: #### 2.3. Example for test:
@@ -60,44 +60,44 @@ Run a specific case in User:
. .
---------------------------------------------------------------------- ----------------------------------------------------------------------
Ran 1 test in 0.005s Ran 1 test in 0.005s
OK OK


#### 2.4. Example for module: #### 2.4. Example for module:
If we want to run tests in the module: If we want to run tests in the module:


/home/frappe/frappe-bench/apps/erpnext/erpnext/support/doctype/issue/test_issue.py /home/frappe/frappe-bench/apps/erpnext/erpnext/support/doctype/issue/test_issue.py
We should use module name like this (related to application folder) We should use module name like this (related to application folder)


erpnext.support.doctype.issue.test_issue erpnext.support.doctype.issue.test_issue
#####EXAMPLE: #####EXAMPLE:
frappe@erpnext:~/frappe-bench$ bench run-tests --module "erpnext.stock.doctype.stock_entry.test_stock_entry" frappe@erpnext:~/frappe-bench$ bench run-tests --module "erpnext.stock.doctype.stock_entry.test_stock_entry"
........................... ...........................
---------------------------------------------------------------------- ----------------------------------------------------------------------
Ran 27 tests in 30.549s Ran 27 tests in 30.549s


#### 2.5. Example for profile: #### 2.5. Example for profile:


frappe@erpnext:~/frappe-bench$ bench run-tests --doctype "Activity Cost" --profile frappe@erpnext:~/frappe-bench$ bench run-tests --doctype "Activity Cost" --profile
. .
---------------------------------------------------------------------- ----------------------------------------------------------------------
Ran 1 test in 0.010s Ran 1 test in 0.010s
OK OK
9133 function calls (8912 primitive calls) in 0.011 seconds 9133 function calls (8912 primitive calls) in 0.011 seconds
Ordered by: cumulative time Ordered by: cumulative time
ncalls tottime percall cumtime percall filename:lineno(function) ncalls tottime percall cumtime percall filename:lineno(function)
2 0.000 0.000 0.008 0.004 /home/frappe/frappe-bench/apps/frappe/frappe/model/document.py:187(insert) 2 0.000 0.000 0.008 0.004 /home/frappe/frappe-bench/apps/frappe/frappe/model/document.py:187(insert)
1 0.000 0.000 0.003 0.003 /home/frappe/frappe-bench/apps/frappe/frappe/model/document.py:386(_validate) 1 0.000 0.000 0.003 0.003 /home/frappe/frappe-bench/apps/frappe/frappe/model/document.py:386(_validate)
13 0.000 0.000 0.002 0.000 /home/frappe/frappe-bench/apps/frappe/frappe/database.py:77(sql) 13 0.000 0.000 0.002 0.000 /home/frappe/frappe-bench/apps/frappe/frappe/database.py:77(sql)
255 0.000 0.000 0.002 0.000 /home/frappe/frappe-bench/apps/frappe/frappe/model/base_document.py:91(get) 255 0.000 0.000 0.002 0.000 /home/frappe/frappe-bench/apps/frappe/frappe/model/base_document.py:91(get)
12 0.000 0.000 0.002 0.000
12 0.000 0.000 0.002 0.000


#### 2.6. Example for XUnit XML: #### 2.6. Example for XUnit XML:


@@ -118,7 +118,7 @@ We should use module name like this (related to application folder)
It’s designed for the CI Jenkins, but will work for anything else that understands an XUnit-formatted XML representation of test results. It’s designed for the CI Jenkins, but will work for anything else that understands an XUnit-formatted XML representation of test results.


#### Jenkins configuration support: #### Jenkins configuration support:
1. You should install xUnit plugin - https://wiki.jenkins-ci.org/display/JENKINS/xUnit+Plugin
1. You should install xUnit plugin - https://wiki.jenkins-ci.org/display/JENKINS/xUnit+Plugin
2. After installation open Jenkins job configuration, click the box named “Publish JUnit test result report” under the "Post-build Actions" and enter path to XML report: 2. After installation open Jenkins job configuration, click the box named “Publish JUnit test result report” under the "Post-build Actions" and enter path to XML report:
(Example: _reports/*.xml_) (Example: _reports/*.xml_)


@@ -197,9 +197,3 @@ It’s designed for the CI Jenkins, but will work for anything else that underst
self.assertTrue("_Test Event 3" in subjects) self.assertTrue("_Test Event 3" in subjects)
self.assertFalse("_Test Event 2" in subjects) self.assertFalse("_Test Event 2" in subjects)



## 4. Client Side Testing (Using Selenium)

This feature is still under development.

For an example see, [https://github.com/frappe/erpnext/blob/develop/erpnext/tests/sel_tests.py](https://github.com/frappe/erpnext/blob/develop/erpnext/tests/sel_tests.py)

+ 1
- 1
frappe/docs/user/en/guides/basics/apps.md View File

@@ -12,7 +12,7 @@ Frappe ships with a boiler plate for a new app. The command `bench make-app
app-name` helps you start a new app by starting an interactive shell. app-name` helps you start a new app by starting an interactive shell.




% bench make-app sample_app
% bench new-app sample_app
App Name: sample_app App Name: sample_app
App Title: Sample App App Title: Sample App
App Description: This is a sample app. App Description: This is a sample app.


+ 1
- 1
frappe/docs/user/fr/tutorial/web-views.md View File

@@ -1,7 +1,7 @@
# Les vues web # Les vues web


Frappe a deux principaux environnements, le **bureau** et **le web**. Le **bureau** est un environnement riche AJAX alors Frappe a deux principaux environnements, le **bureau** et **le web**. Le **bureau** est un environnement riche AJAX alors
que **le web** est une collection plus traditionnelle de fichers HTML pour la consultation publique. Les vues web peuvent
que **le web** est une collection plus traditionnelle de fichiers HTML pour la consultation publique. Les vues web peuvent
aussi être générées pour créer des vues plus controllées pour les utilisateurs qui peuvent se connecter mais qui n'ont pas aussi être générées pour créer des vues plus controllées pour les utilisateurs qui peuvent se connecter mais qui n'ont pas
accès au desk. accès au desk.




+ 2
- 2
frappe/email/doctype/auto_email_report/auto_email_report.json View File

@@ -585,7 +585,7 @@
"label": "Format", "label": "Format",
"length": 0, "length": 0,
"no_copy": 0, "no_copy": 0,
"options": "HTML\nXLS\nCSV",
"options": "HTML\nXLSX\nCSV",
"permlevel": 0, "permlevel": 0,
"precision": "", "precision": "",
"print_hide": 0, "print_hide": 0,
@@ -669,7 +669,7 @@
"issingle": 0, "issingle": 0,
"istable": 0, "istable": 0,
"max_attachments": 0, "max_attachments": 0,
"modified": "2017-04-25 03:31:55.214149",
"modified": "2017-06-30 12:54:13.350902",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Email", "module": "Email",
"name": "Auto Email Report", "name": "Auto Email Report",


+ 11
- 7
frappe/email/doctype/auto_email_report/auto_email_report.py View File

@@ -8,7 +8,7 @@ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from datetime import timedelta from datetime import timedelta
import frappe.utils import frappe.utils
from frappe.utils.xlsutils import get_xls
from frappe.utils.xlsxutils import make_xlsx
from frappe.utils.csvutils import to_csv from frappe.utils.csvutils import to_csv


max_reports_per_user = 3 max_reports_per_user = 3
@@ -43,7 +43,7 @@ class AutoEmailReport(Document):


def validate_report_format(self): def validate_report_format(self):
""" check if user has select correct report format """ """ check if user has select correct report format """
valid_report_formats = ["HTML", "XLS", "CSV"]
valid_report_formats = ["HTML", "XLSX", "CSV"]
if self.format not in valid_report_formats: if self.format not in valid_report_formats:
frappe.throw(_("%s is not a valid report format. Report format should \ frappe.throw(_("%s is not a valid report format. Report format should \
one of the following %s"%(frappe.bold(self.format), frappe.bold(", ".join(valid_report_formats))))) one of the following %s"%(frappe.bold(self.format), frappe.bold(", ".join(valid_report_formats)))))
@@ -70,11 +70,14 @@ class AutoEmailReport(Document):
if self.format == 'HTML': if self.format == 'HTML':
return self.get_html_table(columns, data) return self.get_html_table(columns, data)


elif self.format == 'XLS':
return get_xls(columns, data)
elif self.format == 'XLSX':
spreadsheet_data = self.get_spreadsheet_data(columns, data)
xlsx_file = make_xlsx(spreadsheet_data, "Auto Email Report")
return xlsx_file.getvalue()


elif self.format == 'CSV': elif self.format == 'CSV':
return self.get_csv(columns, data)
spreadsheet_data = self.get_spreadsheet_data(columns, data)
return to_csv(spreadsheet_data)


else: else:
frappe.throw(_('Invalid Output Format')) frappe.throw(_('Invalid Output Format'))
@@ -85,7 +88,8 @@ class AutoEmailReport(Document):
'data': data 'data': data
}) })


def get_csv(self, columns, data):
@staticmethod
def get_spreadsheet_data(columns, data):
out = [[df.label for df in columns], ] out = [[df.label for df in columns], ]
for row in data: for row in data:
new_row = [] new_row = []
@@ -93,7 +97,7 @@ class AutoEmailReport(Document):
for df in columns: for df in columns:
new_row.append(frappe.format(row[df.fieldname], df, row)) new_row.append(frappe.format(row[df.fieldname], df, row))


return to_csv(out)
return out


def get_file_name(self): def get_file_name(self):
return "{0}.{1}".format(self.report.replace(" ", "-").replace("/", "-"), self.format.lower()) return "{0}.{1}".format(self.report.replace(" ", "-").replace("/", "-"), self.format.lower())


+ 1
- 1
frappe/email/doctype/auto_email_report/test_auto_email_report.py View File

@@ -33,7 +33,7 @@ class TestAutoEmailReport(unittest.TestCase):
data = auto_email_report.get_report_content() data = auto_email_report.get_report_content()
self.assertTrue('"Language","Core"' in data) self.assertTrue('"Language","Core"' in data)


auto_email_report.format = 'XLS'
auto_email_report.format = 'XLSX'


data = auto_email_report.get_report_content() data = auto_email_report.get_report_content()



+ 2
- 1
frappe/email/doctype/email_account/email_account.js View File

@@ -119,7 +119,8 @@ frappe.ui.form.on("Email Account", {
}, },


show_gmail_message_for_less_secure_apps: function(frm) { show_gmail_message_for_less_secure_apps: function(frm) {
if(frm.doc.service==="Gmail") {
frm.dashboard.clear_headline();
if(frm.doc.service==="GMail") {
frm.dashboard.set_headline_alert('Gmail will only work if you allow access for less secure \ frm.dashboard.set_headline_alert('Gmail will only work if you allow access for less secure \
apps in Gmail settings. <a target="_blank" \ apps in Gmail settings. <a target="_blank" \
href="https://support.google.com/accounts/answer/6010255?hl=en">Read this for details</a>'); href="https://support.google.com/accounts/answer/6010255?hl=en">Read this for details</a>');


+ 2
- 1
frappe/email/doctype/email_account/email_account.py View File

@@ -330,6 +330,8 @@ class EmailAccount(Document):
# gmail shows sent emails in inbox # gmail shows sent emails in inbox
# and we don't want emails sent by us to be pulled back into the system again # and we don't want emails sent by us to be pulled back into the system again
# dont count emails sent by the system get those # dont count emails sent by the system get those
if frappe.flags.in_test:
print('WARN: Cannot pull email. Sender sames as recipient inbox')
raise SentEmailInInbox raise SentEmailInInbox


if email.message_id: if email.message_id:
@@ -472,7 +474,6 @@ class EmailAccount(Document):
parent = frappe._dict(doctype=self.append_to, name=parent[0].name) parent = frappe._dict(doctype=self.append_to, name=parent[0].name)
return parent return parent



def create_new_parent(self, communication, email): def create_new_parent(self, communication, email):
'''If no parent found, create a new reference document''' '''If no parent found, create a new reference document'''




+ 1
- 0
frappe/email/doctype/email_alert/email_alert.js View File

@@ -18,6 +18,7 @@ frappe.email_alert = {


// set value changed options // set value changed options
frm.set_df_property("value_changed", "options", [""].concat(options)); frm.set_df_property("value_changed", "options", [""].concat(options));
frm.set_df_property("set_property_after_alert", "options", [""].concat(options));


// set date changed options // set date changed options
frm.set_df_property("date_changed", "options", $.map(fields, frm.set_df_property("date_changed", "options", $.map(fields,


+ 141
- 4
frappe/email/doctype/email_alert/email_alert.json View File

@@ -1,5 +1,6 @@
{ {
"allow_copy": 0, "allow_copy": 0,
"allow_guest_to_view": 0,
"allow_import": 0, "allow_import": 0,
"allow_rename": 1, "allow_rename": 1,
"autoname": "Prompt", "autoname": "Prompt",
@@ -13,6 +14,7 @@
"engine": "InnoDB", "engine": "InnoDB",
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -24,6 +26,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Enabled", "label": "Enabled",
@@ -41,6 +44,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -51,6 +55,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Filters", "label": "Filters",
@@ -68,6 +73,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -79,6 +85,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 1, "ignore_xss_filter": 1,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Subject", "label": "Subject",
@@ -96,6 +103,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -106,6 +114,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Document Type", "label": "Document Type",
@@ -124,6 +133,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -134,6 +144,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Is Standard", "label": "Is Standard",
@@ -152,6 +163,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -163,6 +175,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Module", "label": "Module",
@@ -182,6 +195,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -192,6 +206,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"length": 0, "length": 0,
@@ -209,6 +224,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -219,6 +235,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Send Alert On", "label": "Send Alert On",
@@ -237,6 +254,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -249,6 +267,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Trigger Method", "label": "Trigger Method",
@@ -267,6 +286,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -279,6 +299,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Reference Date", "label": "Reference Date",
@@ -296,6 +317,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -309,6 +331,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Days Before or After", "label": "Days Before or After",
@@ -326,6 +349,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -338,6 +362,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Value Changed", "label": "Value Changed",
@@ -355,6 +380,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -365,6 +391,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"length": 0, "length": 0,
@@ -382,6 +409,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -394,6 +422,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 1, "ignore_xss_filter": 1,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Condition", "label": "Condition",
@@ -411,6 +440,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -421,6 +451,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"length": 0, "length": 0,
@@ -437,6 +468,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -447,6 +479,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"length": 0, "length": 0,
@@ -464,6 +497,97 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 1,
"columns": 0,
"fieldname": "property_section",
"fieldtype": "Section Break",
"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": "Set Property After Alert",
"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
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "set_property_after_alert",
"fieldtype": "Select",
"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": "Set Property After Alert",
"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
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "property_value",
"fieldtype": "Data",
"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": "Value To Be Set",
"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
},
{
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -474,6 +598,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Recipients", "label": "Recipients",
@@ -491,6 +616,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -501,6 +627,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Recipients", "label": "Recipients",
@@ -519,6 +646,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -529,6 +657,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Message", "label": "Message",
@@ -546,18 +675,20 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
"columns": 0, "columns": 0,
"default": "Add your message here", "default": "Add your message here",
"depends_on": "",
"depends_on": "eval:!doc.is_standard",
"fieldname": "message", "fieldname": "message",
"fieldtype": "Code", "fieldtype": "Code",
"hidden": 0, "hidden": 0,
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 1, "ignore_xss_filter": 1,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Message", "label": "Message",
@@ -575,6 +706,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -585,6 +717,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Attach Print", "label": "Attach Print",
@@ -603,6 +736,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -613,6 +747,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "Message Examples", "label": "Message Examples",
@@ -631,6 +766,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -641,6 +777,7 @@
"ignore_user_permissions": 0, "ignore_user_permissions": 0,
"ignore_xss_filter": 0, "ignore_xss_filter": 0,
"in_filter": 0, "in_filter": 0,
"in_global_search": 0,
"in_list_view": 0, "in_list_view": 0,
"in_standard_filter": 0, "in_standard_filter": 0,
"label": "View Properties (via Customize Form)", "label": "View Properties (via Customize Form)",
@@ -659,19 +796,19 @@
"unique": 0 "unique": 0
} }
], ],
"has_web_view": 0,
"hide_heading": 0, "hide_heading": 0,
"hide_toolbar": 0, "hide_toolbar": 0,
"icon": "fa fa-envelope", "icon": "fa fa-envelope",
"idx": 0, "idx": 0,
"image_view": 0, "image_view": 0,
"in_create": 0, "in_create": 0,
"in_dialog": 0,
"is_submittable": 0, "is_submittable": 0,
"issingle": 0, "issingle": 0,
"istable": 0, "istable": 0,
"max_attachments": 0, "max_attachments": 0,
"menu_index": 0, "menu_index": 0,
"modified": "2016-12-29 14:40:25.782293",
"modified": "2017-07-07 16:09:48.804218",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Email", "module": "Email",
"name": "Email Alert", "name": "Email Alert",
@@ -688,7 +825,6 @@
"export": 1, "export": 1,
"if_owner": 0, "if_owner": 0,
"import": 0, "import": 0,
"is_custom": 0,
"permlevel": 0, "permlevel": 0,
"print": 0, "print": 0,
"read": 1, "read": 1,
@@ -703,6 +839,7 @@
"quick_entry": 0, "quick_entry": 0,
"read_only": 0, "read_only": 0,
"read_only_onload": 0, "read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"title_field": "subject", "title_field": "subject",


+ 5
- 0
frappe/email/doctype/email_alert/email_alert.py View File

@@ -159,6 +159,11 @@ def get_context(context):
reference_name = doc.name, reference_name = doc.name,
attachments = attachments) attachments = attachments)


if self.set_property_after_alert:
frappe.db.set_value(doc.doctype, doc.name, self.set_property_after_alert,
self.property_value, update_modified = False)
doc.set(self.set_property_after_alert, self.property_value)

def load_standard_properties(self, context): def load_standard_properties(self, context):
module = get_doc_module(self.module, self.doctype, self.name) module = get_doc_module(self.module, self.doctype, self.name)
if module: if module:


+ 6
- 1
frappe/email/doctype/email_alert/test_email_alert.py View File

@@ -7,6 +7,8 @@ import unittest


test_records = frappe.get_test_records('Email Alert') test_records = frappe.get_test_records('Email Alert')


test_dependencies = ["User"]

class TestEmailAlert(unittest.TestCase): class TestEmailAlert(unittest.TestCase):
def setUp(self): def setUp(self):
frappe.db.sql("""delete from `tabEmail Queue`""") frappe.db.sql("""delete from `tabEmail Queue`""")
@@ -32,6 +34,9 @@ class TestEmailAlert(unittest.TestCase):
self.assertTrue(frappe.db.get_value("Email Queue", {"reference_doctype": "Communication", self.assertTrue(frappe.db.get_value("Email Queue", {"reference_doctype": "Communication",
"reference_name": communication.name, "status":"Not Sent"})) "reference_name": communication.name, "status":"Not Sent"}))


self.assertEquals(frappe.db.get_value('Communication',
communication.name, 'subject'), '__testing__')

def test_condition(self): def test_condition(self):
event = frappe.new_doc("Event") event = frappe.new_doc("Event")
event.subject = "test", event.subject = "test",
@@ -137,7 +142,7 @@ class TestEmailAlert(unittest.TestCase):
event.save() event.save()


# Value Change email alert alert will be trigger as description is not changed # Value Change email alert alert will be trigger as description is not changed
# mail will not be sent
# mail will not be sent
self.assertFalse(frappe.db.get_value("Email Queue", {"reference_doctype": "Event", self.assertFalse(frappe.db.get_value("Email Queue", {"reference_doctype": "Event",
"reference_name": event.name, "status":"Not Sent"})) "reference_name": event.name, "status":"Not Sent"}))




+ 3
- 1
frappe/email/doctype/email_alert/test_records.json View File

@@ -21,7 +21,9 @@
"condition": "doc.communication_type=='Comment'", "condition": "doc.communication_type=='Comment'",
"recipients": [ "recipients": [
{ "email_by_document_field": "owner" } { "email_by_document_field": "owner" }
]
],
"set_property_after_alert": "subject",
"property_value": "__testing__"
}, },
{ {
"doctype": "Email Alert", "doctype": "Email Alert",


+ 1
- 1
frappe/email/doctype/email_group/email_group.py View File

@@ -26,7 +26,7 @@ class EmailGroup(Document):


for user in frappe.db.get_all(doctype, [email_field, unsubscribed_field or "name"]): for user in frappe.db.get_all(doctype, [email_field, unsubscribed_field or "name"]):
try: try:
email = parse_addr(user.get(email_field))[1]
email = parse_addr(user.get(email_field))[1] if user.get(email_field) else None
if email: if email:
frappe.get_doc({ frappe.get_doc({
"doctype": "Email Group Member", "doctype": "Email Group Member",


+ 49
- 2
frappe/email/doctype/email_queue/email_queue.json View File

@@ -1,5 +1,6 @@
{ {
"allow_copy": 0, "allow_copy": 0,
"allow_guest_to_view": 0,
"allow_import": 0, "allow_import": 0,
"allow_rename": 0, "allow_rename": 0,
"autoname": "hash", "autoname": "hash",
@@ -14,6 +15,7 @@
"engine": "InnoDB", "engine": "InnoDB",
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -43,6 +45,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -72,6 +75,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -101,6 +105,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -129,6 +134,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -159,6 +165,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -187,6 +194,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -216,6 +224,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -245,6 +254,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -273,6 +283,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -303,6 +314,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -332,6 +344,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -362,6 +375,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -392,6 +406,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -421,6 +436,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -450,6 +466,7 @@
"unique": 0 "unique": 0
}, },
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0, "allow_on_submit": 0,
"bold": 0, "bold": 0,
"collapsible": 0, "collapsible": 0,
@@ -477,20 +494,50 @@
"search_index": 0, "search_index": 0,
"set_only_once": 0, "set_only_once": 0,
"unique": 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_heading": 0,
"hide_toolbar": 0, "hide_toolbar": 0,
"icon": "fa fa-envelope", "icon": "fa fa-envelope",
"idx": 1, "idx": 1,
"image_view": 0, "image_view": 0,
"in_create": 1, "in_create": 1,
"in_dialog": 0,
"is_submittable": 0, "is_submittable": 0,
"issingle": 0, "issingle": 0,
"istable": 0, "istable": 0,
"max_attachments": 0, "max_attachments": 0,
"modified": "2017-02-24 17:42:10.878546",
"modified": "2017-07-07 16:29:15.780393",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Email", "module": "Email",
"name": "Email Queue", "name": "Email Queue",


+ 3
- 2
frappe/email/doctype/newsletter/newsletter.py View File

@@ -70,8 +70,9 @@ class Newsletter(Document):


for file in files: for file in files:
try: 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: except IOError:
frappe.throw(_("Unable to find attachment {0}").format(a)) frappe.throw(_("Unable to find attachment {0}").format(a))




+ 175
- 65
frappe/email/email_body.py View File

@@ -2,7 +2,7 @@
# MIT License. See license.txt # MIT License. See license.txt


from __future__ import unicode_literals from __future__ import unicode_literals
import frappe, re
import frappe, re, os
from frappe.utils.pdf import get_pdf from frappe.utils.pdf import get_pdf
from frappe.email.smtp import get_outgoing_email_account from frappe.email.smtp import get_outgoing_email_account
from frappe.utils import (get_url, scrub_urls, strip, expand_relative_urls, cint, from frappe.utils import (get_url, scrub_urls, strip, expand_relative_urls, cint,
@@ -15,21 +15,31 @@ from email.mime.multipart import MIMEMultipart
def get_email(recipients, sender='', msg='', subject='[No Subject]', def get_email(recipients, sender='', msg='', subject='[No Subject]',
text_content = None, footer=None, print_html=None, formatted=None, attachments=None, text_content = None, footer=None, print_html=None, formatted=None, attachments=None,
content=None, reply_to=None, cc=[], email_account=None, expose_recipients=None, content=None, reply_to=None, cc=[], email_account=None, expose_recipients=None,
inline_images=[]):
"""send an html email as multipart with attachments and all"""
inline_images=[], header=False):
""" Prepare an email with the following format:
- multipart/mixed
- multipart/alternative
- text/plain
- multipart/related
- text/html
- inline image
- attachment
"""
content = content or msg content = content or msg
emailobj = EMail(sender, recipients, subject, reply_to=reply_to, cc=cc, email_account=email_account, expose_recipients=expose_recipients) emailobj = EMail(sender, recipients, subject, reply_to=reply_to, cc=cc, email_account=email_account, expose_recipients=expose_recipients)


if not content.strip().startswith("<"): if not content.strip().startswith("<"):
content = markdown(content) 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) print_html=print_html, formatted=formatted, inline_images=inline_images)


if isinstance(attachments, dict): if isinstance(attachments, dict):
attachments = [attachments] attachments = [attachments]


for attach in (attachments or []): for attach in (attachments or []):
# cannot attach if no filecontent
if attach.get('fcontent') is None: continue
emailobj.add_attachment(**attach) emailobj.add_attachment(**attach)


return emailobj return emailobj
@@ -58,18 +68,19 @@ class EMail:
self.expose_recipients = expose_recipients self.expose_recipients = expose_recipients


self.msg_root = MIMEMultipart('mixed') self.msg_root = MIMEMultipart('mixed')
self.msg_multipart = MIMEMultipart('alternative')
self.msg_root.attach(self.msg_multipart)
self.msg_alternative = MIMEMultipart('alternative')
self.msg_root.attach(self.msg_alternative)
self.cc = cc or [] self.cc = cc or []
self.html_set = False self.html_set = False


self.email_account = email_account or get_outgoing_email_account() self.email_account = email_account or get_outgoing_email_account()


def set_html(self, message, text_content = None, footer=None, print_html=None, 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""" """Attach message in the html portion of multipart/alternative"""
if not formatted: 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, # this is the first html part of a multi-part message,
# convert to text well # convert to text well
@@ -88,33 +99,33 @@ class EMail:
""" """
from email.mime.text import MIMEText from email.mime.text import MIMEText
part = MIMEText(message, 'plain', 'utf-8') part = MIMEText(message, 'plain', 'utf-8')
self.msg_multipart.attach(part)
self.msg_alternative.attach(part)


def set_part_html(self, message, inline_images): def set_part_html(self, message, inline_images):
from email.mime.text import MIMEText from email.mime.text import MIMEText
if inline_images:
related = MIMEMultipart('related')


for image in inline_images:
# images in dict like {filename:'', filecontent:'raw'}
content_id = random_string(10)
has_inline_images = re.search('''embed=['"].*?['"]''', message)


# replace filename in message with CID
message = re.sub('''src=['"]{0}['"]'''.format(image.get('filename')),
'src="cid:{0}"'.format(content_id), message)
if has_inline_images:
# process inline images
message, _inline_images = replace_filename_with_cid(message)


self.add_attachment(image.get('filename'), image.get('filecontent'),
None, content_id=content_id, parent=related)
# prepare parts
msg_related = MIMEMultipart('related')


html_part = MIMEText(message, 'html', 'utf-8') html_part = MIMEText(message, 'html', 'utf-8')
related.attach(html_part)
msg_related.attach(html_part)


self.msg_multipart.attach(related)
for image in _inline_images:
self.add_attachment(image.get('filename'), image.get('filecontent'),
content_id=image.get('content_id'), parent=msg_related, inline=True)

self.msg_alternative.attach(msg_related)
else: else:
self.msg_multipart.attach(MIMEText(message, 'html', 'utf-8'))
self.msg_alternative.attach(MIMEText(message, 'html', 'utf-8'))


def set_html_as_text(self, html): def set_html_as_text(self, html):
"""return html2text"""
"""Set plain text from HTML"""
self.set_text(to_markdown(html)) self.set_text(to_markdown(html))


def set_message(self, message, mime_type='text/html', as_attachment=0, filename='attachment.html'): def set_message(self, message, mime_type='text/html', as_attachment=0, filename='attachment.html'):
@@ -139,50 +150,13 @@ class EMail:
self.add_attachment(res[0], res[1]) self.add_attachment(res[0], res[1])


def add_attachment(self, fname, fcontent, content_type=None, def add_attachment(self, fname, fcontent, content_type=None,
parent=None, content_id=None):
parent=None, content_id=None, inline=False):
"""add attachment""" """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:
part.add_header(b'Content-Disposition',
("attachment; filename=\"%s\"" % fname).encode('utf-8'))
if content_id:
part.add_header(b'Content-ID', '<{0}>'.format(content_id))


if not parent: if not parent:
parent = self.msg_root 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): def add_pdf_attachment(self, name, html, options=None):
self.add_attachment(name, get_pdf(html, options), 'application/octet-stream') self.add_attachment(name, get_pdf(html, options), 'application/octet-stream')
@@ -259,11 +233,12 @@ class EMail:
self.make() self.make()
return self.msg_root.as_string() 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: if not email_account:
email_account = get_outgoing_email_account(False) email_account = get_outgoing_email_account(False)


rendered_email = frappe.get_template("templates/emails/standard.html").render({ rendered_email = frappe.get_template("templates/emails/standard.html").render({
"header": get_header() if header else None,
"content": message, "content": message,
"signature": get_signature(email_account), "signature": get_signature(email_account),
"footer": get_footer(email_account, footer), "footer": get_footer(email_account, footer),
@@ -274,6 +249,52 @@ def get_formatted_html(subject, message, footer=None, print_html=None, email_acc


return scrub_urls(rendered_email) 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(): def get_message_id():
'''Returns Message ID created from doctype and name''' '''Returns Message ID created from doctype and name'''
return "<{unique}@{site}>".format( return "<{unique}@{site}>".format(
@@ -298,11 +319,100 @@ def get_footer(email_account, footer=None):
company_address = frappe.db.get_default("email_footer_address") company_address = frappe.db.get_default("email_footer_address")


if company_address: if company_address:
footer += '<div style="margin: 15px auto; text-align: center; color: #8d99a6">{0}</div>'\
.format(company_address.replace("\n", "<br>"))
company_address = company_address.splitlines(True)
footer += '<table width="100%" border=0>'
footer += '<tr><td height=20></td></tr>'
for x in company_address:
footer += '<tr style="margin: 15px auto; text-align: center; color: #8d99a6"><td>{0}</td></tr>'\
.format(x)
footer += "</table>"


if not cint(frappe.db.get_default("disable_standard_email_footer")): if not cint(frappe.db.get_default("disable_standard_email_footer")):
for default_mail_footer in frappe.get_hooks("default_mail_footer"): for default_mail_footer in frappe.get_hooks("default_mail_footer"):
footer += '<div style="margin: 15px auto;">{0}</div>'.format(default_mail_footer) footer += '<div style="margin: 15px auto;">{0}</div>'.format(default_mail_footer)


return footer return footer

def replace_filename_with_cid(message):
""" Replaces <img embed="assets/frappe/images/filename.jpg" ...> with
<img src="cid:content_id" ...> and return the modified message and
a list of inline_images with {filename, filecontent, content_id}
"""

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

+ 56
- 17
frappe/email/queue.py View File

@@ -5,29 +5,32 @@ from __future__ import unicode_literals
from six.moves import range from six.moves import range
import frappe import frappe
import HTMLParser import HTMLParser
import smtplib, quopri
import smtplib, quopri, json
from frappe import msgprint, throw, _ from frappe import msgprint, throw, _
from frappe.email.smtp import SMTPServer, get_outgoing_email_account 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 frappe.utils.verified_command import get_signed_params, verify_request
from html2text import html2text from html2text import html2text
from frappe.utils import get_url, nowdate, encode, now_datetime, add_days, split_emails, cstr, cint 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 rq.timeouts import JobTimeoutException
from frappe.utils.scheduler import log from frappe.utils.scheduler import log


class EmailLimitCrossedError(frappe.ValidationError): pass class EmailLimitCrossedError(frappe.ValidationError): pass


def send(recipients=None, sender=None, subject=None, message=None, reference_doctype=None,
def send(recipients=None, sender=None, subject=None, message=None, text_content=None, reference_doctype=None,
reference_name=None, unsubscribe_method=None, unsubscribe_params=None, unsubscribe_message=None, 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, 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, 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) """Add email to sending queue (Email Queue)


:param recipients: List of recipients. :param recipients: List of recipients.
:param sender: Email sender. :param sender: Email sender.
:param subject: Email subject. :param subject: Email subject.
:param message: Email message. :param message: Email message.
:param text_content: Text version of email message.
:param reference_doctype: Reference DocType of caller document. :param reference_doctype: Reference DocType of caller document.
:param reference_name: Reference name of caller document. :param reference_name: Reference name of caller document.
:param send_priority: Priority for Email Queue, default 1. :param send_priority: Priority for Email Queue, default 1.
@@ -43,6 +46,7 @@ def send(recipients=None, sender=None, subject=None, message=None, reference_doc
:param is_notification: Marks email as notification so will not trigger notifications from system :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 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 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: if not unsubscribe_method:
unsubscribe_method = "/api/method/frappe.email.queue.unsubscribe" unsubscribe_method = "/api/method/frappe.email.queue.unsubscribe"
@@ -65,12 +69,13 @@ def send(recipients=None, sender=None, subject=None, message=None, reference_doc


check_email_limit(recipients) check_email_limit(recipients)


formatted = get_formatted_html(subject, message, email_account=email_account)
if not text_content:
try:
text_content = html2text(message)
except HTMLParser.HTMLParseError:
text_content = "See html attachment"


try:
text_content = html2text(formatted)
except HTMLParser.HTMLParseError:
text_content = "See html attachment"
formatted = get_formatted_html(subject, message, email_account=email_account, header=header)


if reference_doctype and reference_name: if reference_doctype and reference_name:
unsubscribed = [d.email for d in frappe.db.get_all("Email Unsubscribe", "email", unsubscribed = [d.email for d in frappe.db.get_all("Email Unsubscribe", "email",
@@ -114,6 +119,7 @@ def send(recipients=None, sender=None, subject=None, message=None, reference_doc
queue_separately=queue_separately, queue_separately=queue_separately,
is_notification = is_notification, is_notification = is_notification,
inline_images = inline_images, inline_images = inline_images,
header=header,
now=now) now=now)




@@ -143,6 +149,14 @@ def get_email_queue(recipients, sender, subject, **kwargs):
'''Make Email Queue object''' '''Make Email Queue object'''
e = frappe.new_doc('Email Queue') e = frappe.new_doc('Email Queue')
e.priority = kwargs.get('send_priority') 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: try:
mail = get_email(recipients, mail = get_email(recipients,
@@ -155,7 +169,8 @@ def get_email_queue(recipients, sender, subject, **kwargs):
cc=kwargs.get('cc'), cc=kwargs.get('cc'),
email_account=kwargs.get('email_account'), email_account=kwargs.get('email_account'),
expose_recipients=kwargs.get('expose_recipients'), 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')) mail.set_message_id(kwargs.get('message_id'),kwargs.get('is_notification'))
if kwargs.get('read_receipt'): if kwargs.get('read_receipt'):
@@ -331,7 +346,7 @@ def send_one(email, smtpserver=None, auto_commit=True, now=False, from_test=Fals
email = frappe.db.sql('''select email = frappe.db.sql('''select
name, status, communication, message, sender, reference_doctype, name, status, communication, message, sender, reference_doctype,
reference_name, unsubscribe_param, unsubscribe_method, expose_recipients, reference_name, unsubscribe_param, unsubscribe_method, expose_recipients,
show_as_cc, add_unsubscribe_link
show_as_cc, add_unsubscribe_link, attachments
from from
`tabEmail Queue` `tabEmail Queue`
where where
@@ -424,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) frappe.get_doc('Communication', email.communication).set_delivery_status(commit=auto_commit)


if now: if now:
print(frappe.get_traceback())
raise e raise e


else: else:
@@ -457,7 +473,31 @@ def prepare_message(email, recipient, recipients_list):
message = message.replace("<!--cc message-->", quopri.encodestring(email_sent_message)) message = message.replace("<!--cc message-->", quopri.encodestring(email_sent_message))


message = message.replace("<!--recipient-->", recipient) message = message.replace("<!--recipient-->", 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(): def clear_outbox():
"""Remove low priority older than 31 days in Outbox and expire mails not sent for 7 days. """Remove low priority older than 31 days in Outbox and expire mails not sent for 7 days.
@@ -475,8 +515,7 @@ def clear_outbox():
frappe.db.sql("""delete from `tabEmail Queue Recipient` where parent in (%s)""" frappe.db.sql("""delete from `tabEmail Queue Recipient` where parent in (%s)"""
% ','.join(['%s']*len(email_queues)), tuple(email_queues)) % ','.join(['%s']*len(email_queues)), tuple(email_queues))


for dt in ("Email Queue", "Email Queue Recipient"):
frappe.db.sql("""
update `tab{0}`
set status='Expired'
where datediff(curdate(), modified) > 7 and status='Not Sent'""".format(dt))
frappe.db.sql("""
update `tabEmail Queue`
set status='Expired'
where datediff(curdate(), modified) > 7 and status='Not Sent' and (send_after is null or send_after < %(now)s)""", { 'now': now_datetime() })

+ 101
- 0
frappe/email/test_email_body.py View File

@@ -0,0 +1,101 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from __future__ import unicode_literals

import frappe, unittest, os, base64
from frappe.email.email_body import replace_filename_with_cid, get_email

class TestEmailBody(unittest.TestCase):
def setUp(self):
email_html = '''
<div>
<h3>Hey John Doe!</h3>
<p>This is embedded image you asked for</p>
<img embed="assets/frappe/images/favicon.png" />
</div>
'''
email_text = '''
Hey John Doe!
This is the text version of this email
'''

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)

# email body keeps 76 characters on one line
self.img_base64 = fixed_column_width(img_base64, 76)

self.email_string = get_email(
recipients=['test@example.com'],
sender='me@example.com',
subject='Test Subject',
content=email_html,
text_content=email_text
).as_string()


def test_image(self):
img_signature = '''
Content-Type: image/png
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Disposition: inline; filename="favicon.png"
'''

self.assertTrue(img_signature in self.email_string)
self.assertTrue(self.img_base64 in self.email_string)


def test_text_content(self):
text_content = '''
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: quoted-printable


Hey John Doe!
This is the text version of this email
'''
self.assertTrue(text_content in self.email_string)


def test_email_content(self):
html_head = '''
Content-Type: text/html; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: quoted-printable

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.=
w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns=3D"http://www.w3.org/1999/xhtml">
'''

html = '''<h3>Hey John Doe!</h3>'''

self.assertTrue(html_head in self.email_string)
self.assertTrue(html in self.email_string)


def test_replace_filename_with_cid(self):
original_message = '''
<div>
<img embed="assets/frappe/images/favicon.png" alt="test" />
<img embed="notexists.jpg" />
</div>
'''
message, inline_images = replace_filename_with_cid(original_message)

processed_message = '''
<div>
<img src="cid:{0}" alt="test" />
<img />
</div>
'''.format(inline_images[0].get('content_id'))
self.assertEquals(message, processed_message)


def fixed_column_width(string, chunk_size):
parts = [string[0+i:chunk_size+i] for i in range(0, len(string), chunk_size)]
return '\n'.join(parts)

+ 1
- 1
frappe/frappeclient.py View File

@@ -102,7 +102,7 @@ class FrappeClient(object):
:param doctype: `doctype` to be deleted :param doctype: `doctype` to be deleted
:param name: `name` of document to be deleted''' :param name: `name` of document to be deleted'''
return self.post_request({ return self.post_request({
"cmd": "frappe.model.delete_doc",
"cmd": "frappe.client.delete",
"doctype": doctype, "doctype": doctype,
"name": name "name": name
}) })


+ 3
- 0
frappe/integrations/doctype/dropbox_settings/dropbox_settings.py View File

@@ -272,6 +272,9 @@ def set_dropbox_access_token(access_token):
frappe.db.commit() frappe.db.commit()


def generate_oauth2_access_token_from_oauth1_token(dropbox_settings=None): def generate_oauth2_access_token_from_oauth1_token(dropbox_settings=None):
if not dropbox_settings.get("access_key") or not dropbox_settings.get("access_secret"):
return {}

url = "https://api.dropboxapi.com/2/auth/token/from_oauth1" url = "https://api.dropboxapi.com/2/auth/token/from_oauth1"
headers = {"Content-Type": "application/json"} headers = {"Content-Type": "application/json"}
auth = (dropbox_settings["app_key"], dropbox_settings["app_secret"]) auth = (dropbox_settings["app_key"], dropbox_settings["app_secret"])


+ 1
- 2
frappe/model/base_document.py View File

@@ -423,12 +423,11 @@ class BaseDocument(object):
return "{}: {}: {}".format(_("Error"), _("Data missing in table"), _(df.label)) return "{}: {}: {}".format(_("Error"), _("Data missing in table"), _(df.label))


elif self.parentfield: elif self.parentfield:

return "{}: {} {} #{}: {}: {}".format(_("Error"), frappe.bold(_(self.doctype)), return "{}: {} {} #{}: {}: {}".format(_("Error"), frappe.bold(_(self.doctype)),
_("Row"), self.idx, _("Value missing for"), _(df.label)) _("Row"), self.idx, _("Value missing for"), _(df.label))


else: else:
return "{}: {}: {}".format(_("Error"), _("Value missing for"), _(df.label))
return _("Error: Value missing for {0}: {1}").format(_(df.parent), _(df.label))


missing = [] missing = []




+ 10
- 5
frappe/model/db_query.py View File

@@ -423,7 +423,6 @@ class DatabaseQuery(object):
def add_user_permissions(self, user_permissions, user_permission_doctypes=None): def add_user_permissions(self, user_permissions, user_permission_doctypes=None):
user_permission_doctypes = frappe.permissions.get_user_permission_doctypes(user_permission_doctypes, user_permissions) user_permission_doctypes = frappe.permissions.get_user_permission_doctypes(user_permission_doctypes, user_permissions)
meta = frappe.get_meta(self.doctype) meta = frappe.get_meta(self.doctype)

for doctypes in user_permission_doctypes: for doctypes in user_permission_doctypes:
match_filters = {} match_filters = {}
match_conditions = [] match_conditions = []
@@ -431,12 +430,18 @@ class DatabaseQuery(object):
for df in meta.get_fields_to_check_permissions(doctypes): for df in meta.get_fields_to_check_permissions(doctypes):
user_permission_values = user_permissions.get(df.options, []) user_permission_values = user_permissions.get(df.options, [])


condition = 'ifnull(`tab{doctype}`.`{fieldname}`, "")=""'.format(doctype=self.doctype, fieldname=df.fieldname)
cond = 'ifnull(`tab{doctype}`.`{fieldname}`, "")=""'.format(doctype=self.doctype, fieldname=df.fieldname)
if user_permission_values: if user_permission_values:
condition += """ or `tab{doctype}`.`{fieldname}` in ({values})""".format(
if not cint(frappe.get_system_settings("apply_strict_user_permissions")):
condition = cond + " or "
else:
condition = ""
condition += """`tab{doctype}`.`{fieldname}` in ({values})""".format(
doctype=self.doctype, fieldname=df.fieldname, doctype=self.doctype, fieldname=df.fieldname,
values=", ".join([('"'+frappe.db.escape(v, percent=False)+'"') for v in user_permission_values])
)
values=", ".join([('"'+frappe.db.escape(v, percent=False)+'"') for v in user_permission_values]))
else:
condition = cond

match_conditions.append("({condition})".format(condition=condition)) match_conditions.append("({condition})".format(condition=condition))


match_filters[df.options] = user_permission_values match_filters[df.options] = user_permission_values


+ 9
- 1
frappe/model/db_schema.py View File

@@ -43,6 +43,7 @@ type_map = {
,'Attach': ('text', '') ,'Attach': ('text', '')
,'Attach Image':('text', '') ,'Attach Image':('text', '')
,'Signature': ('longtext', '') ,'Signature': ('longtext', '')
,'Color': ('varchar', varchar_len)
} }


default_columns = ['name', 'creation', 'modified', 'modified_by', 'owner', default_columns = ['name', 'creation', 'modified', 'modified_by', 'owner',
@@ -307,7 +308,7 @@ class DbTable:
if not frappe.db.sql("show index from `%s` where key_name = %s" % if not frappe.db.sql("show index from `%s` where key_name = %s" %
(self.name, '%s'), col.fieldname): (self.name, '%s'), col.fieldname):
query.append("add index `{}`(`{}`)".format(col.fieldname, col.fieldname)) query.append("add index `{}`(`{}`)".format(col.fieldname, col.fieldname))
for col in self.drop_index: for col in self.drop_index:
if col.fieldname != 'name': # primary key if col.fieldname != 'name': # primary key
# if index key exists # if index key exists
@@ -563,6 +564,13 @@ def validate_column_name(n):
frappe.throw(_("Fieldname {0} cannot have special characters like {1}").format(cstr(n), special_characters), InvalidColumnName) frappe.throw(_("Fieldname {0} cannot have special characters like {1}").format(cstr(n), special_characters), InvalidColumnName)
return n return n


def validate_column_length(fieldname):
""" In MySQL maximum column length is 64 characters,
ref: https://dev.mysql.com/doc/refman/5.5/en/identifiers.html"""

if len(fieldname) > 64:
frappe.throw(_("Fieldname is limited to 64 characters ({0})").format(fieldname))

def remove_all_foreign_keys(): def remove_all_foreign_keys():
frappe.db.sql("set foreign_key_checks = 0") frappe.db.sql("set foreign_key_checks = 0")
frappe.db.commit() frappe.db.commit()


+ 1
- 0
frappe/model/delete_doc.py View File

@@ -59,6 +59,7 @@ def delete_doc(doctype=None, name=None, force=0, ignore_doctypes=None, for_reloa
frappe.db.sql("delete from `tabCustom Script` where dt = %s", name) frappe.db.sql("delete from `tabCustom Script` where dt = %s", name)
frappe.db.sql("delete from `tabProperty Setter` where doc_type = %s", name) frappe.db.sql("delete from `tabProperty Setter` where doc_type = %s", name)
frappe.db.sql("delete from `tabReport` where ref_doctype=%s", name) frappe.db.sql("delete from `tabReport` where ref_doctype=%s", name)
frappe.db.sql("delete from `tabCustom DocPerm` where parent=%s", name)


delete_from_table(doctype, name, ignore_doctypes, None) delete_from_table(doctype, name, ignore_doctypes, None)




+ 6
- 0
frappe/model/naming.py View File

@@ -99,6 +99,9 @@ def make_autoname(key='', doctype='', doc=''):


def parse_naming_series(parts, doctype= '', doc = ''): def parse_naming_series(parts, doctype= '', doc = ''):
n = '' n = ''
if isinstance(parts, basestring):
parts = parts.split('.')

series_set = False series_set = False
today = now_datetime() today = now_datetime()
for e in parts: for e in parts:
@@ -142,6 +145,9 @@ def getseries(key, digits, doctype=''):
def revert_series_if_last(key, name): def revert_series_if_last(key, name):
if ".#" in key: if ".#" in key:
prefix, hashes = key.rsplit(".", 1) prefix, hashes = key.rsplit(".", 1)
if '.' in prefix:
prefix = parse_naming_series(prefix.split('.'))

if "#" not in hashes: if "#" not in hashes:
return return
else: else:


+ 1
- 0
frappe/model/rename_doc.py View File

@@ -162,6 +162,7 @@ def update_link_field_values(link_fields, old, new, doctype):
single_doc.set(field['fieldname'], new) single_doc.set(field['fieldname'], new)
# update single docs using ORM rather then query # update single docs using ORM rather then query
# as single docs also sometimes sets defaults! # as single docs also sometimes sets defaults!
single_doc.flags.ignore_mandatory = True
single_doc.save(ignore_permissions=True) single_doc.save(ignore_permissions=True)
except ImportError: except ImportError:
# fails in patches where the doctype has been renamed # fails in patches where the doctype has been renamed


+ 2
- 2
frappe/modules/utils.py View File

@@ -180,8 +180,8 @@ def load_doctype_module(doctype, module=None, prefix="", suffix=""):
try: try:
if key not in doctype_python_modules: if key not in doctype_python_modules:
doctype_python_modules[key] = frappe.get_module(module_name) doctype_python_modules[key] = frappe.get_module(module_name)
except ImportError:
raise ImportError('Module import failed for {0} ({1})'.format(doctype, module_name))
except ImportError, e:
raise ImportError('Module import failed for {0} ({1})'.format(doctype, module_name + ' Error: ' + str(e)))


return doctype_python_modules[key] return doctype_python_modules[key]




+ 0
- 12
frappe/nightwatch.global.js View File

@@ -1,12 +0,0 @@
var chromedriver = require('chromedriver');
module.exports = {
before: function (done) {
chromedriver.start();
done();
},

after: function (done) {
chromedriver.stop();
done();
}
};

+ 0
- 96
frappe/nightwatch.js View File

@@ -1,96 +0,0 @@
const fs = require('fs');

const ci_mode = get_cli_arg('env') === 'ci_server';
const site_name = get_cli_arg('site');
let app_name = get_cli_arg('app');

if(!app_name) {
console.log('Please specify app to run tests');
return;
}

if(!ci_mode && !site_name) {
console.log('Please specify site to run tests');
return;
}

// site url
let site_url;
if(site_name) {
site_url = 'http://' + site_name + ':' + get_port();
}

// multiple apps
if(app_name.includes(',')) {
app_name = app_name.split(',');
} else {
app_name = [app_name];
}

let test_folders = [];
let page_objects = [];
for(const app of app_name) {
const test_folder = `apps/${app}/${app}/tests/ui`;
const page_object = test_folder + '/page_objects';

if(!fs.existsSync(test_folder)) {
console.log(`No test folder found for "${app}"`);
continue;
}
test_folders.push(test_folder);

if(fs.existsSync(page_object)) {
page_objects.push();
}
}

const config = {
"src_folders": test_folders,
"globals_path" : 'apps/frappe/frappe/nightwatch.global.js',
"page_objects_path": page_objects,
"selenium": {
"start_process": false
},
"test_settings": {
"default": {
"launch_url": site_url,
"selenium_port": 9515,
"selenium_host": "127.0.0.1",
"default_path_prefix": "",
"silent": true,
// "screenshots": {
// "enabled": true,
// "path": SCREENSHOT_PATH
// },
"globals": {
"waitForConditionTimeout": 15000
},
"desiredCapabilities": {
"browserName": "chrome",
"chromeOptions": {
"args": ["--no-sandbox", "--start-maximized"]
},
"javascriptEnabled": true,
"acceptSslCerts": true
}
},
"ci_server": {
"launch_url": 'http://localhost:8000'
}
}
}
module.exports = config;

function get_cli_arg(key) {
var args = process.argv;
var i = args.indexOf('--' + key);
if(i === -1) {
return null;
}
return args[i + 1];
}

function get_port() {
var bench_config = JSON.parse(fs.readFileSync('sites/common_site_config.json'));
return bench_config.webserver_port;
}

+ 2
- 0
frappe/patches.txt View File

@@ -186,3 +186,5 @@ frappe.patches.v8_0.update_desktop_icons
frappe.patches.v8_0.update_gender_and_salutation frappe.patches.v8_0.update_gender_and_salutation
execute:frappe.db.sql('update tabReport set module="Desk" where name="ToDo"') execute:frappe.db.sql('update tabReport set module="Desk" where name="ToDo"')
frappe.patches.v8_1.enable_allow_error_traceback_in_system_settings frappe.patches.v8_1.enable_allow_error_traceback_in_system_settings
frappe.patches.v8_1.update_format_options_in_auto_email_report
frappe.patches.v8_1.delete_custom_docperm_if_doctype_not_exists

+ 6
- 0
frappe/patches/v8_1/delete_custom_docperm_if_doctype_not_exists.py View File

@@ -0,0 +1,6 @@
import frappe

def execute():
frappe.db.sql("""delete from `tabCustom DocPerm`
where parent not in ( select name from `tabDocType` )
""")

+ 14
- 0
frappe/patches/v8_1/update_format_options_in_auto_email_report.py View File

@@ -0,0 +1,14 @@
# Copyright (c) 2017, Frappe and Contributors
# License: GNU General Public License v3. See license.txt

from __future__ import unicode_literals
import frappe

def execute():
""" change the XLS option as XLSX in the auto email report """

frappe.reload_doc("email", "doctype", "auto_email_report")

auto_email_list = frappe.get_all("Auto Email Report", filters={"format": "XLS"})
for auto_email in auto_email_list:
frappe.db.set_value("Auto Email Report", auto_email.name, "format", "XLSX")

+ 1
- 1
frappe/printing/doctype/print_format/test_print_format.py View File

@@ -1,6 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
from __future__ import unicode_literals
from __future__ import unicode_literals, print_function


import frappe import frappe
import unittest import unittest


+ 3
- 0
frappe/public/build.json View File

@@ -104,6 +104,7 @@


"public/js/frappe/ui/page.html", "public/js/frappe/ui/page.html",
"public/js/frappe/ui/page.js", "public/js/frappe/ui/page.js",
"public/js/frappe/ui/find.js",
"public/js/frappe/ui/iconbar.js", "public/js/frappe/ui/iconbar.js",
"public/js/frappe/form/layout.js", "public/js/frappe/form/layout.js",
"public/js/frappe/ui/field_group.js", "public/js/frappe/ui/field_group.js",
@@ -197,6 +198,8 @@
"public/js/frappe/form/save.js", "public/js/frappe/form/save.js",
"public/js/frappe/form/script_manager.js", "public/js/frappe/form/script_manager.js",
"public/js/frappe/form/grid.js", "public/js/frappe/form/grid.js",
"public/js/frappe/form/grid_row.js",
"public/js/frappe/form/grid_row_form.js",
"public/js/frappe/form/linked_with.js", "public/js/frappe/form/linked_with.js",
"public/js/frappe/form/workflow.js", "public/js/frappe/form/workflow.js",
"public/js/frappe/form/print.js", "public/js/frappe/form/print.js",


+ 48
- 0
frappe/public/css/desk.css View File

@@ -995,3 +995,51 @@ input[type="checkbox"]:checked:before {
visibility: visible; visibility: visible;
} }
} }
.color-picker {
position: relative;
z-index: 999;
}
.color-picker .color-picker-pallete {
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
background: #fff;
border: 1px solid #d1d8dd;
width: 290px;
height: 106px;
padding-top: 10px;
padding-left: 5px;
position: absolute;
top: 0;
left: 0;
}
.color-picker .color-picker-pallete:after,
.color-picker .color-picker-pallete:before {
border: solid transparent;
content: " ";
height: 0;
width: 0;
pointer-events: none;
position: absolute;
bottom: 100%;
left: 30px;
}
.color-picker .color-picker-pallete:after {
border-color: rgba(255, 255, 255, 0);
border-bottom-color: #fff;
border-width: 8px;
margin-left: -8px;
}
.color-picker .color-picker-pallete:before {
border-color: rgba(221, 221, 221, 0);
border-bottom-color: #d1d8dd;
border-width: 9px;
margin-left: -9px;
}
.color-picker .color-box {
cursor: pointer;
display: inline-block;
width: 20px;
height: 20px;
margin: -2px 0 0 3px;
border: 1px solid rgba(0, 0, 0, 0.25);
}

+ 20
- 17
frappe/public/css/form.css View File

@@ -28,7 +28,8 @@
border-top: 1px solid #d1d8dd; border-top: 1px solid #d1d8dd;
} }
.form-message { .form-message {
padding: 15px;
padding: 15px 30px;
border-bottom: 1px solid #d1d8dd;
} }
.document-flow-wrapper { .document-flow-wrapper {
padding: 40px 15px 30px; padding: 40px 15px 30px;
@@ -73,21 +74,24 @@
} }
.form-dashboard { .form-dashboard {
background-color: #fafbfc; background-color: #fafbfc;
border-bottom: 1px solid #d1d8dd;
}
.form-dashboard-wrapper {
margin: -15px 0px;
} }
.form-documents h6 { .form-documents h6 {
margin-top: 15px; margin-top: 15px;
} }
.form-dashboard-section { .form-dashboard-section {
margin: 0px -15px;
padding: 15px 30px; padding: 15px 30px;
border-bottom: 1px solid #EBEFF2; border-bottom: 1px solid #EBEFF2;
} }
.form-dashboard-section:first-child {
padding-top: 0px;
}
.form-dashboard-section:last-child { .form-dashboard-section:last-child {
border-bottom: none; border-bottom: none;
} }
.form-heatmap {
padding-top: 30px;
}
.form-heatmap .heatmap-message { .form-heatmap .heatmap-message {
margin-top: 10px; margin-top: 10px;
} }
@@ -496,6 +500,7 @@ h6.uppercase,
} }
.like-disabled-input.for-description { .like-disabled-input.for-description {
font-weight: normal; font-weight: normal;
font-size: 12px;
} }
.frappe-control { .frappe-control {
margin-bottom: 10px; margin-bottom: 10px;
@@ -530,19 +535,17 @@ select.form-control {
font-weight: bold; font-weight: bold;
background-color: #fffdf4; background-color: #fffdf4;
} }
.form-headline {
padding: 0px 15px;
margin: 0px;
.form-control[data-fieldtype="Password"] {
position: inherit;
} }
.form-headline .alert {
font-size: 12px;
background-color: #fffce7;
font-weight: normal !important;
border: 0px;
border-radius: 0px;
margin-bottom: 0px;
margin: 0px -15px;
padding: 10px 30px;
.password-strength-indicator {
float: right;
padding: 15px;
margin-top: -41px;
margin-right: -7px;
}
.password-strength-message {
margin-top: -10px;
} }
.delivery-status-indicator { .delivery-status-indicator {
display: inline-block; display: inline-block;


+ 20
- 0
frappe/public/js/frappe/dom.js View File

@@ -195,6 +195,26 @@ frappe.ellipsis = function(text, max) {
return text; return text;
}; };


frappe.run_serially = function(tasks) {
var result = Promise.resolve();
tasks.forEach(task => {
if(task) {
result = result.then ? result.then(task) : Promise.resolve();
}
});
return result;
};

frappe.timeout = seconds => {
return new Promise((resolve) => {
setTimeout(() => resolve(), seconds * 1000);
});
};

frappe.scrub = function(text) {
return text.replace(/ /g, "_").toLowerCase();
};

frappe.get_modal = function(title, content) { frappe.get_modal = function(title, content) {
return $(frappe.render_template("modal", {title:title, content:content})).appendTo(document.body); return $(frappe.render_template("modal", {title:title, content:content})).appendTo(document.body);
}; };


+ 428
- 266
frappe/public/js/frappe/form/control.js
File diff suppressed because it is too large
View File


+ 12
- 15
frappe/public/js/frappe/form/dashboard.js View File

@@ -4,10 +4,11 @@
frappe.ui.form.Dashboard = Class.extend({ frappe.ui.form.Dashboard = Class.extend({
init: function(opts) { init: function(opts) {
$.extend(this, opts); $.extend(this, opts);
this.section = this.frm.fields_dict._form_dashboard.wrapper;
this.parent = this.section.find('.section-body');
this.wrapper = $(frappe.render_template('form_dashboard', this.wrapper = $(frappe.render_template('form_dashboard',
{frm: this.frm})).prependTo(this.frm.layout.wrapper);
{frm: this.frm})).appendTo(this.parent);


this.headline = this.wrapper.find('.form-headline');
this.progress_area = this.wrapper.find(".progress-area"); this.progress_area = this.wrapper.find(".progress-area");
this.heatmap_area = this.wrapper.find('.form-heatmap'); this.heatmap_area = this.wrapper.find('.form-heatmap');
this.chart_area = this.wrapper.find('.form-chart'); this.chart_area = this.wrapper.find('.form-chart');
@@ -18,7 +19,7 @@ frappe.ui.form.Dashboard = Class.extend({


}, },
reset: function() { reset: function() {
this.wrapper.addClass('hidden');
this.section.addClass('hidden');
this.clear_headline(); this.clear_headline();


// clear progress // clear progress
@@ -36,13 +37,10 @@ frappe.ui.form.Dashboard = Class.extend({
this.wrapper.find('.custom').remove(); this.wrapper.find('.custom').remove();
}, },
set_headline: function(html) { set_headline: function(html) {
this.headline.html(html).removeClass('hidden');
this.show();
this.frm.layout.show_message(html);
}, },
clear_headline: function() { clear_headline: function() {
if(this.headline) {
this.headline.empty().addClass('hidden');
}
this.frm.layout.show_message();
}, },


add_comment: function(text, permanent) { add_comment: function(text, permanent) {
@@ -59,13 +57,12 @@ frappe.ui.form.Dashboard = Class.extend({
this.clear_headline(); this.clear_headline();
}, },


set_headline_alert: function(text, alert_class) {
set_headline_alert: function(text, indicator_color) {
if (!indicator_color) {
indicator_color = 'orange';
}
if(text) { if(text) {
if(!alert_class) alert_class = "alert-warning";
this.set_headline(repl('<div class="alert %(alert_class)s">%(text)s</div>', {
"alert_class": alert_class || "",
"text": text
}));
this.set_headline(`<div><span class="indicator ${indicator_color}">${text}</span></div>`);
} else { } else {
this.clear_headline(); this.clear_headline();
} }
@@ -406,6 +403,6 @@ frappe.ui.form.Dashboard = Class.extend({
} }
}, },
show: function() { show: function() {
this.wrapper.removeClass('hidden');
this.section.removeClass('hidden');
} }
}); });

+ 3
- 3
frappe/public/js/frappe/form/footer/timeline.js View File

@@ -421,7 +421,7 @@ frappe.ui.form.Timeline = Class.extend({
out.push(me.get_version_comment(version, __('cancelled this document'))); out.push(me.get_version_comment(version, __('cancelled this document')));
} }
} else { } else {
var df = frappe.meta.get_docfield(me.frm.doctype, p[0], me.frm.docname); var df = frappe.meta.get_docfield(me.frm.doctype, p[0], me.frm.docname);


if(df && !df.hidden) { if(df && !df.hidden) {
@@ -448,8 +448,8 @@ frappe.ui.form.Timeline = Class.extend({
var parts = [], count = 0; var parts = [], count = 0;
data.row_changed.every(function(row) { data.row_changed.every(function(row) {
row[3].every(function(p) { row[3].every(function(p) {
var df = me.frm.fields_dict[row[0]] &&
frappe.meta.get_docfield(me.frm.fields_dict[row[0]].grid.doctype,
var df = me.frm.fields_dict[row[0]] &&
frappe.meta.get_docfield(me.frm.fields_dict[row[0]].grid.doctype,
p[0], me.frm.docname); p[0], me.frm.docname);


if(df && !df.hidden) { if(df && !df.hidden) {


+ 3
- 2
frappe/public/js/frappe/form/footer/timeline_item.html View File

@@ -73,7 +73,8 @@
</a> </a>
{% } %} {% } %}


{% if (data.communication_medium === "Email" && data.sender !== user_email) { %}
{% if (data.communication_medium === "Email"
&& data.sender !== frappe.session.user_email) { %}
<a class="text-muted reply-link pull-right timeline-content-show" <a class="text-muted reply-link pull-right timeline-content-show"
data-name="{%= data.name %}">{%= __("Reply") %}</a> data-name="{%= data.name %}">{%= __("Reply") %}</a>
{% } %} {% } %}
@@ -110,7 +111,7 @@
{% $.each(data.attachments, function(i, a) { %} {% $.each(data.attachments, function(i, a) { %}
<div class="ellipsis"> <div class="ellipsis">
<a href="{%= encodeURI(a.file_url).replace(/#/g, \'%23\') %}" <a href="{%= encodeURI(a.file_url).replace(/#/g, \'%23\') %}"
class="text-muted small" target="_blank">
class="text-muted small" target="_blank" rel="noopener noreferrer">
<i class="fa fa-paperclip"></i> <i class="fa fa-paperclip"></i>
{%= a.file_url.split("/").slice(-1)[0] %} {%= a.file_url.split("/").slice(-1)[0] %}
{% if (a.is_private) { %} {% if (a.is_private) { %}


+ 21
- 0
frappe/public/js/frappe/form/formatters.js View File

@@ -53,6 +53,17 @@ frappe.form.formatters = {
Currency: function(value, docfield, options, doc) { Currency: function(value, docfield, options, doc) {
var currency = frappe.meta.get_field_currency(docfield, doc); var currency = frappe.meta.get_field_currency(docfield, doc);
var precision = docfield.precision || cint(frappe.boot.sysdefaults.currency_precision) || 2; var precision = docfield.precision || cint(frappe.boot.sysdefaults.currency_precision) || 2;
if (precision > 2) {
let parts = cstr(value).split('.');
let decimals = parts.length > 1 ? parts[1] : '';
if (decimals.length < 3) {
// min precision 2
precision = 2;
} else if (decimals.length < precision) {
// or min decimals
precision = decimals.length;
}
}
return frappe.form.formatters._right((value==null || value==="") return frappe.form.formatters._right((value==null || value==="")
? "" : format_currency(value, currency, precision), options); ? "" : format_currency(value, currency, precision), options);
}, },
@@ -108,6 +119,16 @@ frappe.form.formatters = {


return value || ""; return value || "";
}, },
DateRange: function(value) {
if($.isArray(value)) {
return __("{0} to {1}").format([
frappe.datetime.str_to_user(value[0]),
frappe.datetime.str_to_user(value[1])
]);
} else {
return value || "";
}
},
Datetime: function(value) { Datetime: function(value) {
if(value) { if(value) {
var m = moment(frappe.datetime.convert_to_user_tz(value)); var m = moment(frappe.datetime.convert_to_user_tz(value));


+ 34
- 687
frappe/public/js/frappe/form/grid.js View File

@@ -108,6 +108,11 @@ frappe.ui.form.Grid = Class.extend({
select_row: function(name) { select_row: function(name) {
this.grid_rows_by_docname[name].select(); this.grid_rows_by_docname[name].select();
}, },
remove_all: function() {
this.grid_rows.forEach(row => {
row.remove();
});
},
refresh_remove_rows_button: function() { refresh_remove_rows_button: function() {
this.remove_rows_button.toggleClass('hide', this.remove_rows_button.toggleClass('hide',
this.wrapper.find('.grid-body .grid-row-check:checked:first').length ? false : true); this.wrapper.find('.grid-body .grid-row-check:checked:first').length ? false : true);
@@ -257,7 +262,7 @@ frappe.ui.form.Grid = Class.extend({
if (this.frm && this.frm.docname) { if (this.frm && this.frm.docname) {
// use doc specific docfield object // use doc specific docfield object
this.df = frappe.meta.get_docfield(this.frm.doctype, this.df.fieldname, this.df = frappe.meta.get_docfield(this.frm.doctype, this.df.fieldname,
this.frm.docname);
this.frm.docname);
} else { } else {
// use non-doc specific docfield // use non-doc specific docfield
if(this.df.options) { if(this.df.options) {
@@ -360,8 +365,19 @@ frappe.ui.form.Grid = Class.extend({
get_docfield: function(fieldname) { get_docfield: function(fieldname) {
return frappe.meta.get_docfield(this.doctype, fieldname, this.frm ? this.frm.docname : null); return frappe.meta.get_docfield(this.doctype, fieldname, this.frm ? this.frm.docname : null);
}, },
get_grid_row: function(docname) {
return this.grid_rows_by_docname[docname];
get_row: function(key) {
if(typeof key == 'number') {
if(key < 0) {
return this.grid_rows[this.grid_rows.length + key];
} else {
return this.grid_rows[key];
}
} else {
return this.grid_rows_by_docname[key];
}
},
get_grid_row: function(key) {
return this.get_row(key);
}, },
get_field: function(fieldname) { get_field: function(fieldname) {
// Note: workaround for get_query // Note: workaround for get_query
@@ -435,21 +451,21 @@ frappe.ui.form.Grid = Class.extend({
&& (this.frm && this.frm.get_perm(df.permlevel, "read") || !this.frm) && (this.frm && this.frm.get_perm(df.permlevel, "read") || !this.frm)
&& !in_list(frappe.model.layout_fields, df.fieldtype)) { && !in_list(frappe.model.layout_fields, df.fieldtype)) {


if(df.columns) {
df.colsize=df.columns;
}
else {
var colsize=2;
switch(df.fieldtype){
case"Text":
case"Small Text":
colsize=3;
break;
case"Check":
colsize=1
}
df.colsize=colsize
if(df.columns) {
df.colsize=df.columns;
}
else {
var colsize=2;
switch(df.fieldtype) {
case"Text":
case"Small Text":
colsize=3;
break;
case"Check":
colsize=1
} }
df.colsize=colsize;
}


if(df.columns) { if(df.columns) {
df.colsize=df.columns; df.colsize=df.columns;
@@ -641,673 +657,4 @@ frappe.ui.form.Grid = Class.extend({
// hide all custom buttons // hide all custom buttons
this.grid_buttons.find('.btn-custom').addClass('hidden'); this.grid_buttons.find('.btn-custom').addClass('hidden');
} }
});

frappe.ui.form.GridRow = Class.extend({
init: function(opts) {
this.on_grid_fields_dict = {};
this.on_grid_fields = [];
this.row_check_html = '<input type="checkbox" class="grid-row-check pull-left">';
this.columns = {};
this.columns_list = [];
$.extend(this, opts);
this.make();
},
make: function() {
var me = this;

this.wrapper = $('<div class="grid-row"></div>').appendTo(this.parent).data("grid_row", this);
this.row = $('<div class="data-row row"></div>').appendTo(this.wrapper)
.on("click", function(e) {
if($(e.target).hasClass('grid-row-check') || $(e.target).hasClass('row-index') || $(e.target).parent().hasClass('row-index')) {
return;
}
if(me.grid.allow_on_grid_editing() && me.grid.is_editable()) {
// pass
} else {
me.toggle_view();
return false;
}
});

// no checkboxes if too small
// if(this.is_too_small()) {
// this.row_check_html = '';
// }

if(this.grid.template && !this.grid.meta.editable_grid) {
this.render_template();
} else {
this.render_row();
}
if(this.doc) {
this.set_data();
}
},
set_data: function() {
this.wrapper.data({
"doc": this.doc
})
},
set_row_index: function() {
if(this.doc) {
this.wrapper
.attr('data-name', this.doc.name)
.attr("data-idx", this.doc.idx)
.find(".row-index span, .grid-form-row-index").html(this.doc.idx)

}
},
select: function(checked) {
this.doc.__checked = checked ? 1 : 0;
},
refresh_check: function() {
this.wrapper.find('.grid-row-check').prop('checked', this.doc ? !!this.doc.__checked : false);
this.grid.refresh_remove_rows_button();
},
remove: function() {
var me = this;
if(this.grid.is_editable()) {
if(this.frm) {
if(this.get_open_form()) {
this.hide_form();
}

this.frm.script_manager.trigger("before_" + this.grid.df.fieldname + "_remove",
this.doc.doctype, this.doc.name);

//this.wrapper.toggle(false);
frappe.model.clear_doc(this.doc.doctype, this.doc.name);

this.frm.script_manager.trigger(this.grid.df.fieldname + "_remove",
this.doc.doctype, this.doc.name);
this.frm.dirty();
} else {
this.grid.df.data = this.grid.df.data.filter(function(d) {
return d.name !== me.doc.name;
})
// remap idxs
this.grid.df.data.forEach(function(d, i) {
d.idx = i+1;
});
}
this.grid.refresh();
}
},
insert: function(show, below) {
var idx = this.doc.idx;
if(below) idx ++;
this.toggle_view(false);
this.grid.add_new_row(idx, null, show);
},
refresh: function() {
if(this.frm && this.doc) {
this.doc = locals[this.doc.doctype][this.doc.name];
}
// re write columns
this.visible_columns = null;

if(this.grid.template && !this.grid.meta.editable_grid) {
this.render_template();
} else {
this.render_row(true);
}

// refersh form fields
if(this.grid_form) {
this.grid_form.layout && this.grid_form.layout.refresh(this.doc);
}
},
render_template: function() {
this.set_row_index();

if(this.row_display) {
this.row_display.remove();
}
var index_html = '';

// row index
if(this.doc) {
if(!this.row_index) {
this.row_index = $('<div style="float: left; margin-left: 15px; margin-top: 8px; \
margin-right: -20px;">'+this.row_check_html+' <span></span></div>').appendTo(this.row);
}
this.row_index.find('span').html(this.doc.idx);
}

this.row_display = $('<div class="row-data sortable-handle template-row">'+
+'</div>').appendTo(this.row)
.html(frappe.render(this.grid.template, {
doc: this.doc ? frappe.get_format_helper(this.doc) : null,
frm: this.frm,
row: this
}));
},
render_row: function(refresh) {
var me = this;
this.set_row_index();

// index (1, 2, 3 etc)
if(!this.row_index) {
var txt = (this.doc ? this.doc.idx : "&nbsp;");
this.row_index = $(
`<div class="row-index sortable-handle col col-xs-1">
${this.row_check_html}
<span>${txt}</span></div>`)
.appendTo(this.row)
.on('click', function(e) {
if(!$(e.target).hasClass('grid-row-check')) {
me.toggle_view();
}
});
} else {
this.row_index.find('span').html(txt);
}

this.setup_columns();
this.add_open_form_button();
this.refresh_check();

if(this.frm && this.doc) {
$(this.frm.wrapper).trigger("grid-row-render", [this]);
}
},

make_editable: function() {
this.row.toggleClass('editable-row', this.grid.is_editable());
},

is_too_small: function() {
return this.row.width() ? this.row.width() < 300 : false;
},

add_open_form_button: function() {
var me = this;
if(this.doc && !this.grid.df.in_place_edit) {
// remove row
if(!this.open_form_button) {
this.open_form_button = $('<a class="close btn-open-row">\
<span class="octicon octicon-triangle-down"></span></a>')
.appendTo($('<div class="col col-xs-1 sortable-handle"></div>').appendTo(this.row))
.on('click', function() { me.toggle_view(); return false; });

if(this.is_too_small()) {
// narrow
this.open_form_button.css({'margin-right': '-2px'});
}
}
}
},

setup_columns: function() {
var me = this;
this.focus_set = false;
this.grid.setup_visible_columns();

for(var ci in this.grid.visible_columns) {
var df = this.grid.visible_columns[ci][0],
colsize = this.grid.visible_columns[ci][1],
txt = this.doc ?
frappe.format(this.doc[df.fieldname], df, null, this.doc) :
__(df.label);

if(this.doc && df.fieldtype === "Select") {
txt = __(txt);
}

if(!this.columns[df.fieldname]) {
var column = this.make_column(df, colsize, txt, ci);
} else {
var column = this.columns[df.fieldname];
this.refresh_field(df.fieldname, txt);
}

// background color for cellz
if(this.doc) {
if(df.reqd && !txt) {
column.addClass('error');
}
if (df.reqd || df.bold) {
column.addClass('bold');
}
}
}
},

make_column: function(df, colsize, txt, ci) {
var me = this;
var add_class = ((["Text", "Small Text"].indexOf(df.fieldtype)!==-1) ?
" grid-overflow-no-ellipsis" : "");
add_class += (["Int", "Currency", "Float", "Percent"].indexOf(df.fieldtype)!==-1) ?
" text-right": "";
add_class += (["Check"].indexOf(df.fieldtype)!==-1) ?
" text-center": "";

var $col = $('<div class="col grid-static-col col-xs-'+colsize+' '+add_class+'"></div>')
.attr("data-fieldname", df.fieldname)
.attr("data-fieldtype", df.fieldtype)
.data("df", df)
.appendTo(this.row)
.on('click', function() {
if(frappe.ui.form.editable_row===me) {
return;
}
var out = me.toggle_editable_row();
var col = this;
setTimeout(function() {
$(col).find('input[type="Text"]:first').focus();
}, 500);
return out;
});

$col.field_area = $('<div class="field-area"></div>').appendTo($col).toggle(false);
$col.static_area = $('<div class="static-area ellipsis"></div>').appendTo($col).html(txt);
$col.df = df;
$col.column_index = ci;

this.columns[df.fieldname] = $col;
this.columns_list.push($col);

return $col;
},

toggle_editable_row: function(show) {
var me = this;
// show static for field based on
// whether grid is editable
if(this.grid.allow_on_grid_editing() && this.grid.is_editable() && this.doc && show !== false) {

// disable other editale row
if(frappe.ui.form.editable_row
&& frappe.ui.form.editable_row !== this) {
frappe.ui.form.editable_row.toggle_editable_row(false);
}

this.row.toggleClass('editable-row', true);

// setup controls
this.columns_list.forEach(function(column) {
me.make_control(column);
column.static_area.toggle(false);
column.field_area.toggle(true);
});

frappe.ui.form.editable_row = this;
return false;
} else {
this.row.toggleClass('editable-row', false);
this.columns_list.forEach(function(column) {
column.static_area.toggle(true);
column.field_area && column.field_area.toggle(false);
});
frappe.ui.form.editable_row = null;
}
},

make_control: function(column) {
if(column.field) return;

var me = this,
parent = column.field_area,
df = column.df;

// no text editor in grid
if (df.fieldtype=='Text Editor') {
df.fieldtype = 'Text';
}

var field = frappe.ui.form.make_control({
df: df,
parent: parent,
only_input: true,
with_link_btn: true,
doc: this.doc,
doctype: this.doc.doctype,
docname: this.doc.name,
frm: this.grid.frm,
grid: this.grid,
grid_row: this,
value: this.doc[df.fieldname]
});

// sync get_query
field.get_query = this.grid.get_field(df.fieldname).get_query;
field.refresh();
if(field.$input) {
field.$input
.addClass('input-sm')
.attr('data-col-idx', column.column_index)
.attr('placeholder', __(df.label));
// flag list input
if (this.columns_list && this.columns_list.slice(-1)[0]===column) {
field.$input.attr('data-last-input', 1);
}
}

this.set_arrow_keys(field);
column.field = field;
this.on_grid_fields_dict[df.fieldname] = field;
this.on_grid_fields.push(field);

},

set_arrow_keys: function(field) {
var me = this;
if(field.$input) {
field.$input.on('keydown', function(e) {
var { TAB, UP_ARROW, DOWN_ARROW } = frappe.ui.keyCode;
if(!in_list([TAB, UP_ARROW, DOWN_ARROW], e.which)) {
return;
}

var values = me.grid.get_data();
var fieldname = $(this).attr('data-fieldname');
var fieldtype = $(this).attr('data-fieldtype');

var move_up_down = function(base) {
if(in_list(['Text', 'Small Text'], fieldtype)) {
return;
}

base.toggle_editable_row();
setTimeout(function() {
var input = base.columns[fieldname].field.$input;
if(input) {
input.focus();
}
}, 400)

}

// TAB
if(e.which==TAB && !e.shiftKey) {
// last column
if($(this).attr('data-last-input') ||
me.grid.wrapper.find('.grid-row :input:enabled:last').get(0)===this) {
setTimeout(function() {
if(me.doc.idx === values.length) {
// last row
me.grid.add_new_row(null, null, true);
me.grid.grid_rows[me.grid.grid_rows.length - 1].toggle_editable_row();
me.grid.set_focus_on_row();
} else {
me.grid.grid_rows[me.doc.idx].toggle_editable_row();
me.grid.set_focus_on_row(me.doc.idx+1);
}
}, 500);
}
} else if(e.which==UP_ARROW) {
if(me.doc.idx > 1) {
var prev = me.grid.grid_rows[me.doc.idx-2];
move_up_down(prev);
}
} else if(e.which==DOWN_ARROW) {
if(me.doc.idx < values.length) {
var next = me.grid.grid_rows[me.doc.idx];
move_up_down(next);
}
}

});
}
},

get_open_form: function() {
return frappe.ui.form.get_open_grid_form();
},

toggle_view: function(show, callback) {
if(!this.doc) {
return this;
}

if(this.frm) {
// reload doc
this.doc = locals[this.doc.doctype][this.doc.name];
}

// hide other
var open_row = this.get_open_form();

if (show===undefined) show = !!!open_row;

// call blur
document.activeElement && document.activeElement.blur();

if(show && open_row) {
if(open_row==this) {
// already open, do nothing
callback && callback();
return;
} else {
// close other views
open_row.toggle_view(false);
}
}

if(show) {
this.show_form();
} else {
this.hide_form();
}
callback && callback();

return this;
},
show_form: function() {
if(!this.grid_form) {
this.grid_form = new frappe.ui.form.GridRowForm({
row: this
});
}
this.grid_form.render();
this.row.toggle(false);
// this.form_panel.toggle(true);
frappe.dom.freeze("", "dark");
cur_frm.cur_grid = this;
this.wrapper.addClass("grid-row-open");
if(!frappe.dom.is_element_in_viewport(this.wrapper)) {
frappe.utils.scroll_to(this.wrapper, true, 15);
}

if(this.frm) {
this.frm.script_manager.trigger(this.doc.parentfield + "_on_form_rendered");
this.frm.script_manager.trigger("form_render", this.doc.doctype, this.doc.name);
}
},
hide_form: function() {
frappe.dom.unfreeze();
this.row.toggle(true);
this.refresh();
cur_frm.cur_grid = null;
this.wrapper.removeClass("grid-row-open");
},
open_prev: function() {
if(this.grid.grid_rows[this.doc.idx-2]) {
this.grid.grid_rows[this.doc.idx-2].toggle_view(true);
}
},
open_next: function() {
if(this.grid.grid_rows[this.doc.idx]) {
this.grid.grid_rows[this.doc.idx].toggle_view(true);
} else {
this.grid.add_new_row(null, null, true);
}
},
refresh_field: function(fieldname, txt) {
var df = this.grid.get_docfield(fieldname) || undefined;

// format values if no frm
if(!df) {
df = this.grid.visible_columns.find((col) => {
return col[0].fieldname === fieldname;
});
if(df && this.doc) {
var txt = frappe.format(this.doc[fieldname], df[0],
null, this.doc);
}
}

if(txt===undefined && this.frm) {
var txt = frappe.format(this.doc[fieldname], df,
null, this.frm.doc);
}

// reset static value
var column = this.columns[fieldname];
if(column) {
column.static_area.html(txt || "");
if(df && df.reqd) {
column.toggleClass('error', !!(txt===null || txt===''));
}
}

// reset field value
var field = this.on_grid_fields_dict[fieldname];
if(field) {
field.docname = this.doc.name;
field.refresh();
}

// in form
if(this.grid_form) {
this.grid_form.refresh_field(fieldname);
}
},
get_visible_columns: function(blacklist) {
var me = this;
var visible_columns = $.map(this.docfields, function(df) {
var visible = !df.hidden && df.in_list_view && me.grid.frm.get_perm(df.permlevel, "read")
&& !in_list(frappe.model.layout_fields, df.fieldtype) && !in_list(blacklist, df.fieldname);

return visible ? df : null;
});
return visible_columns;
},
set_field_property: function(fieldname, property, value) {
// set a field property for open form / grid form
var me = this;

var set_property = function(field) {
if(!field) return;
field.df[property] = value;
field.refresh();
}

// set property in grid form
if(this.grid_form) {
set_property(this.grid_form.fields_dict[fieldname]);
this.grid_form.layout && this.grid_form.layout.refresh_sections();
}

// set property in on grid fields
set_property(this.on_grid_fields_dict[fieldname]);
},
toggle_reqd: function(fieldname, reqd) {
this.set_field_property(fieldname, 'reqd', reqd ? 1 : 0);
},
toggle_display: function(fieldname, show) {
this.set_field_property(fieldname, 'hidden', show ? 0 : 1);
},
toggle_editable: function(fieldname, editable) {
this.set_field_property(fieldname, 'read_only', editable ? 0 : 1);
},
});

frappe.ui.form.GridRowForm = Class.extend({
init: function(opts) {
$.extend(this, opts);
this.wrapper = $('<div class="form-in-grid"></div>')
.appendTo(this.row.wrapper);

},
render: function() {
var me = this;
this.make_form();
this.form_area.empty();

this.layout = new frappe.ui.form.Layout({
fields: this.row.docfields,
body: this.form_area,
no_submit_on_enter: true,
frm: this.row.frm,
});
this.layout.make();

this.fields = this.layout.fields;
this.fields_dict = this.layout.fields_dict;

this.layout.refresh(this.row.doc);

// copy get_query to fields
for(var fieldname in (this.row.grid.fieldinfo || {})) {
var fi = this.row.grid.fieldinfo[fieldname];
$.extend(me.fields_dict[fieldname], fi);
}

this.toggle_add_delete_button_display(this.wrapper);

this.row.grid.open_grid_row = this;

this.set_focus();
},
make_form: function() {
if(!this.form_area) {
$(frappe.render_template("grid_form", {grid:this})).appendTo(this.wrapper);
this.form_area = this.wrapper.find(".form-area");
this.row.set_row_index();
this.set_form_events();
}
},
set_form_events: function() {
var me = this;
this.wrapper.find(".grid-delete-row")
.on('click', function() {
me.row.remove(); return false;
});
this.wrapper.find(".grid-insert-row")
.on('click', function() {
me.row.insert(true); return false;
});
this.wrapper.find(".grid-insert-row-below")
.on('click', function() {
me.row.insert(true, true); return false;
});
this.wrapper.find(".grid-append-row")
.on('click', function() {
me.row.toggle_view(false);
me.row.grid.add_new_row(me.row.doc.idx+1, null, true);
return false;
});
this.wrapper.find(".grid-form-heading, .grid-footer-toolbar").on("click", function() {
me.row.toggle_view();
return false;
});
},
toggle_add_delete_button_display: function($parent) {
$parent.find(".grid-header-toolbar .btn, .grid-footer-toolbar .btn")
.toggle(this.row.grid.is_editable());
},
refresh_field: function(fieldname) {
if(this.fields_dict[fieldname]) {
this.fields_dict[fieldname].refresh();
this.layout && this.layout.refresh_dependency();
}
},
set_focus: function() {
// wait for animation and then focus on the first row
var me = this;
setTimeout(function() {
if(me.row.frm && me.row.frm.doc.docstatus===0 || !me.row.frm) {
var first = me.form_area.find("input:first");
if(first.length && !in_list(["Date", "Datetime", "Time"], first.attr("data-fieldtype"))) {
try {
first.get(0).focus();
} catch(e) {
//
}
}
}
}, 500);
},
});
});

+ 586
- 0
frappe/public/js/frappe/form/grid_row.js View File

@@ -0,0 +1,586 @@
frappe.ui.form.GridRow = Class.extend({
init: function(opts) {
this.on_grid_fields_dict = {};
this.on_grid_fields = [];
this.row_check_html = '<input type="checkbox" class="grid-row-check pull-left">';
this.columns = {};
this.columns_list = [];
$.extend(this, opts);
this.make();
},
make: function() {
var me = this;

this.wrapper = $('<div class="grid-row"></div>').appendTo(this.parent).data("grid_row", this);
this.row = $('<div class="data-row row"></div>').appendTo(this.wrapper)
.on("click", function(e) {
if($(e.target).hasClass('grid-row-check') || $(e.target).hasClass('row-index') || $(e.target).parent().hasClass('row-index')) {
return;
}
if(me.grid.allow_on_grid_editing() && me.grid.is_editable()) {
// pass
} else {
me.toggle_view();
return false;
}
});

// no checkboxes if too small
// if(this.is_too_small()) {
// this.row_check_html = '';
// }

if(this.grid.template && !this.grid.meta.editable_grid) {
this.render_template();
} else {
this.render_row();
}
if(this.doc) {
this.set_data();
}
},
set_data: function() {
this.wrapper.data({
"doc": this.doc
})
},
set_row_index: function() {
if(this.doc) {
this.wrapper
.attr('data-name', this.doc.name)
.attr("data-idx", this.doc.idx)
.find(".row-index span, .grid-form-row-index").html(this.doc.idx)

}
},
select: function(checked) {
this.doc.__checked = checked ? 1 : 0;
},
refresh_check: function() {
this.wrapper.find('.grid-row-check').prop('checked', this.doc ? !!this.doc.__checked : false);
this.grid.refresh_remove_rows_button();
},
remove: function() {
var me = this;
if(this.grid.is_editable()) {
if(this.frm) {
if(this.get_open_form()) {
this.hide_form();
}

this.frm.script_manager.trigger("before_" + this.grid.df.fieldname + "_remove",
this.doc.doctype, this.doc.name);

//this.wrapper.toggle(false);
frappe.model.clear_doc(this.doc.doctype, this.doc.name);

this.frm.script_manager.trigger(this.grid.df.fieldname + "_remove",
this.doc.doctype, this.doc.name);
this.frm.dirty();
} else {
this.grid.df.data = this.grid.df.data.filter(function(d) {
return d.name !== me.doc.name;
})
// remap idxs
this.grid.df.data.forEach(function(d, i) {
d.idx = i+1;
});
}
this.grid.refresh();
}
},
insert: function(show, below) {
var idx = this.doc.idx;
if(below) idx ++;
this.toggle_view(false);
this.grid.add_new_row(idx, null, show);
},
refresh: function() {
if(this.frm && this.doc) {
this.doc = locals[this.doc.doctype][this.doc.name];
}
// re write columns
this.visible_columns = null;

if(this.grid.template && !this.grid.meta.editable_grid) {
this.render_template();
} else {
this.render_row(true);
}

// refersh form fields
if(this.grid_form) {
this.grid_form.layout && this.grid_form.layout.refresh(this.doc);
}
},
render_template: function() {
this.set_row_index();

if(this.row_display) {
this.row_display.remove();
}
var index_html = '';

// row index
if(this.doc) {
if(!this.row_index) {
this.row_index = $('<div style="float: left; margin-left: 15px; margin-top: 8px; \
margin-right: -20px;">'+this.row_check_html+' <span></span></div>').appendTo(this.row);
}
this.row_index.find('span').html(this.doc.idx);
}

this.row_display = $('<div class="row-data sortable-handle template-row">'+
+'</div>').appendTo(this.row)
.html(frappe.render(this.grid.template, {
doc: this.doc ? frappe.get_format_helper(this.doc) : null,
frm: this.frm,
row: this
}));
},
render_row: function(refresh) {
var me = this;
this.set_row_index();

// index (1, 2, 3 etc)
if(!this.row_index) {
var txt = (this.doc ? this.doc.idx : "&nbsp;");
this.row_index = $(
`<div class="row-index sortable-handle col col-xs-1">
${this.row_check_html}
<span>${txt}</span></div>`)
.appendTo(this.row)
.on('click', function(e) {
if(!$(e.target).hasClass('grid-row-check')) {
me.toggle_view();
}
});
} else {
this.row_index.find('span').html(txt);
}

this.setup_columns();
this.add_open_form_button();
this.refresh_check();

if(this.frm && this.doc) {
$(this.frm.wrapper).trigger("grid-row-render", [this]);
}
},

make_editable: function() {
this.row.toggleClass('editable-row', this.grid.is_editable());
},

is_too_small: function() {
return this.row.width() ? this.row.width() < 300 : false;
},

add_open_form_button: function() {
var me = this;
if(this.doc && !this.grid.df.in_place_edit) {
// remove row
if(!this.open_form_button) {
this.open_form_button = $('<a class="close btn-open-row">\
<span class="octicon octicon-triangle-down"></span></a>')
.appendTo($('<div class="col col-xs-1 sortable-handle"></div>').appendTo(this.row))
.on('click', function() { me.toggle_view(); return false; });

if(this.is_too_small()) {
// narrow
this.open_form_button.css({'margin-right': '-2px'});
}
}
}
},

setup_columns: function() {
var me = this;
this.focus_set = false;
this.grid.setup_visible_columns();

for(var ci in this.grid.visible_columns) {
var df = this.grid.visible_columns[ci][0],
colsize = this.grid.visible_columns[ci][1],
txt = this.doc ?
frappe.format(this.doc[df.fieldname], df, null, this.doc) :
__(df.label);

if(this.doc && df.fieldtype === "Select") {
txt = __(txt);
}

if(!this.columns[df.fieldname]) {
var column = this.make_column(df, colsize, txt, ci);
} else {
var column = this.columns[df.fieldname];
this.refresh_field(df.fieldname, txt);
}

// background color for cellz
if(this.doc) {
if(df.reqd && !txt) {
column.addClass('error');
}
if (df.reqd || df.bold) {
column.addClass('bold');
}
}
}
},

make_column: function(df, colsize, txt, ci) {
var me = this;
var add_class = ((["Text", "Small Text"].indexOf(df.fieldtype)!==-1) ?
" grid-overflow-no-ellipsis" : "");
add_class += (["Int", "Currency", "Float", "Percent"].indexOf(df.fieldtype)!==-1) ?
" text-right": "";
add_class += (["Check"].indexOf(df.fieldtype)!==-1) ?
" text-center": "";

var $col = $('<div class="col grid-static-col col-xs-'+colsize+' '+add_class+'"></div>')
.attr("data-fieldname", df.fieldname)
.attr("data-fieldtype", df.fieldtype)
.data("df", df)
.appendTo(this.row)
.on('click', function() {
if(frappe.ui.form.editable_row===me) {
return;
}
var out = me.toggle_editable_row();
var col = this;
setTimeout(function() {
$(col).find('input[type="Text"]:first').focus();
}, 500);
return out;
});

$col.field_area = $('<div class="field-area"></div>').appendTo($col).toggle(false);
$col.static_area = $('<div class="static-area ellipsis"></div>').appendTo($col).html(txt);
$col.df = df;
$col.column_index = ci;

this.columns[df.fieldname] = $col;
this.columns_list.push($col);

return $col;
},

activate: function() {
this.toggle_editable_row(true);
return this;
},

toggle_editable_row: function(show) {
var me = this;
// show static for field based on
// whether grid is editable
if(this.grid.allow_on_grid_editing() && this.grid.is_editable() && this.doc && show !== false) {

// disable other editale row
if(frappe.ui.form.editable_row
&& frappe.ui.form.editable_row !== this) {
frappe.ui.form.editable_row.toggle_editable_row(false);
}

this.row.toggleClass('editable-row', true);

// setup controls
this.columns_list.forEach(function(column) {
me.make_control(column);
column.static_area.toggle(false);
column.field_area.toggle(true);
});

frappe.ui.form.editable_row = this;
return false;
} else {
this.row.toggleClass('editable-row', false);
this.columns_list.forEach(function(column) {
column.static_area.toggle(true);
column.field_area && column.field_area.toggle(false);
});
frappe.ui.form.editable_row = null;
}
},

make_control: function(column) {
if(column.field) return;

var me = this,
parent = column.field_area,
df = column.df;

// no text editor in grid
if (df.fieldtype=='Text Editor') {
df.fieldtype = 'Text';
}

var field = frappe.ui.form.make_control({
df: df,
parent: parent,
only_input: true,
with_link_btn: true,
doc: this.doc,
doctype: this.doc.doctype,
docname: this.doc.name,
frm: this.grid.frm,
grid: this.grid,
grid_row: this,
value: this.doc[df.fieldname]
});

// sync get_query
field.get_query = this.grid.get_field(df.fieldname).get_query;
field.refresh();
if(field.$input) {
field.$input
.addClass('input-sm')
.attr('data-col-idx', column.column_index)
.attr('placeholder', __(df.label));
// flag list input
if (this.columns_list && this.columns_list.slice(-1)[0]===column) {
field.$input.attr('data-last-input', 1);
}
}

this.set_arrow_keys(field);
column.field = field;
this.on_grid_fields_dict[df.fieldname] = field;
this.on_grid_fields.push(field);

},

set_arrow_keys: function(field) {
var me = this;
if(field.$input) {
field.$input.on('keydown', function(e) {
var { TAB, UP_ARROW, DOWN_ARROW } = frappe.ui.keyCode;
if(!in_list([TAB, UP_ARROW, DOWN_ARROW], e.which)) {
return;
}

var values = me.grid.get_data();
var fieldname = $(this).attr('data-fieldname');
var fieldtype = $(this).attr('data-fieldtype');

var move_up_down = function(base) {
if(in_list(['Text', 'Small Text'], fieldtype)) {
return;
}

base.toggle_editable_row();
setTimeout(function() {
var input = base.columns[fieldname].field.$input;
if(input) {
input.focus();
}
}, 400)

}

// TAB
if(e.which==TAB && !e.shiftKey) {
// last column
if($(this).attr('data-last-input') ||
me.grid.wrapper.find('.grid-row :input:enabled:last').get(0)===this) {
setTimeout(function() {
if(me.doc.idx === values.length) {
// last row
me.grid.add_new_row(null, null, true);
me.grid.grid_rows[me.grid.grid_rows.length - 1].toggle_editable_row();
me.grid.set_focus_on_row();
} else {
me.grid.grid_rows[me.doc.idx].toggle_editable_row();
me.grid.set_focus_on_row(me.doc.idx+1);
}
}, 500);
}
} else if(e.which==UP_ARROW) {
if(me.doc.idx > 1) {
var prev = me.grid.grid_rows[me.doc.idx-2];
move_up_down(prev);
}
} else if(e.which==DOWN_ARROW) {
if(me.doc.idx < values.length) {
var next = me.grid.grid_rows[me.doc.idx];
move_up_down(next);
}
}

});
}
},

get_open_form: function() {
return frappe.ui.form.get_open_grid_form();
},

toggle_view: function(show, callback) {
if(!this.doc) {
return this;
}

if(this.frm) {
// reload doc
this.doc = locals[this.doc.doctype][this.doc.name];
}

// hide other
var open_row = this.get_open_form();

if (show===undefined) show = !!!open_row;

// call blur
document.activeElement && document.activeElement.blur();

if(show && open_row) {
if(open_row==this) {
// already open, do nothing
callback && callback();
return;
} else {
// close other views
open_row.toggle_view(false);
}
}

if(show) {
this.show_form();
} else {
this.hide_form();
}
callback && callback();

return this;
},
show_form: function() {
if(!this.grid_form) {
this.grid_form = new frappe.ui.form.GridRowForm({
row: this
});
}
this.grid_form.render();
this.row.toggle(false);
// this.form_panel.toggle(true);
frappe.dom.freeze("", "dark");
cur_frm.cur_grid = this;
this.wrapper.addClass("grid-row-open");
if(!frappe.dom.is_element_in_viewport(this.wrapper)) {
frappe.utils.scroll_to(this.wrapper, true, 15);
}

if(this.frm) {
this.frm.script_manager.trigger(this.doc.parentfield + "_on_form_rendered");
this.frm.script_manager.trigger("form_render", this.doc.doctype, this.doc.name);
}
},
hide_form: function() {
frappe.dom.unfreeze();
this.row.toggle(true);
this.refresh();
cur_frm.cur_grid = null;
this.wrapper.removeClass("grid-row-open");
},
open_prev: function() {
if(this.grid.grid_rows[this.doc.idx-2]) {
this.grid.grid_rows[this.doc.idx-2].toggle_view(true);
}
},
open_next: function() {
if(this.grid.grid_rows[this.doc.idx]) {
this.grid.grid_rows[this.doc.idx].toggle_view(true);
} else {
this.grid.add_new_row(null, null, true);
}
},
refresh_field: function(fieldname, txt) {
var df = this.grid.get_docfield(fieldname) || undefined;

// format values if no frm
if(!df) {
df = this.grid.visible_columns.find((col) => {
return col[0].fieldname === fieldname;
});
if(df && this.doc) {
var txt = frappe.format(this.doc[fieldname], df[0],
null, this.doc);
}
}

if(txt===undefined && this.frm) {
var txt = frappe.format(this.doc[fieldname], df,
null, this.frm.doc);
}

// reset static value
var column = this.columns[fieldname];
if(column) {
column.static_area.html(txt || "");
if(df && df.reqd) {
column.toggleClass('error', !!(txt===null || txt===''));
}
}

// reset field value
var field = this.on_grid_fields_dict[fieldname];
if(field) {
field.docname = this.doc.name;
field.refresh();
}

// in form
if(this.grid_form) {
this.grid_form.refresh_field(fieldname);
}
},
get_field: function(fieldname) {
let field = this.on_grid_fields_dict[fieldname];
if (field) {
return field;
} else if(this.grid_form) {
return this.grid_form.fields_dict[fieldname];
} else {
throw `fieldname ${fieldname} not found`;
}
},

get_visible_columns: function(blacklist) {
var me = this;
var visible_columns = $.map(this.docfields, function(df) {
var visible = !df.hidden && df.in_list_view && me.grid.frm.get_perm(df.permlevel, "read")
&& !in_list(frappe.model.layout_fields, df.fieldtype) && !in_list(blacklist, df.fieldname);

return visible ? df : null;
});
return visible_columns;
},
set_field_property: function(fieldname, property, value) {
// set a field property for open form / grid form
var me = this;

var set_property = function(field) {
if(!field) return;
field.df[property] = value;
field.refresh();
}

// set property in grid form
if(this.grid_form) {
set_property(this.grid_form.fields_dict[fieldname]);
this.grid_form.layout && this.grid_form.layout.refresh_sections();
}

// set property in on grid fields
set_property(this.on_grid_fields_dict[fieldname]);
},
toggle_reqd: function(fieldname, reqd) {
this.set_field_property(fieldname, 'reqd', reqd ? 1 : 0);
},
toggle_display: function(fieldname, show) {
this.set_field_property(fieldname, 'hidden', show ? 0 : 1);
},
toggle_editable: function(fieldname, editable) {
this.set_field_property(fieldname, 'read_only', editable ? 0 : 1);
},
});

+ 97
- 0
frappe/public/js/frappe/form/grid_row_form.js View File

@@ -0,0 +1,97 @@
frappe.ui.form.GridRowForm = Class.extend({
init: function(opts) {
$.extend(this, opts);
this.wrapper = $('<div class="form-in-grid"></div>')
.appendTo(this.row.wrapper);

},
render: function() {
var me = this;
this.make_form();
this.form_area.empty();

this.layout = new frappe.ui.form.Layout({
fields: this.row.docfields,
body: this.form_area,
no_submit_on_enter: true,
frm: this.row.frm,
});
this.layout.make();

this.fields = this.layout.fields;
this.fields_dict = this.layout.fields_dict;

this.layout.refresh(this.row.doc);

// copy get_query to fields
for(var fieldname in (this.row.grid.fieldinfo || {})) {
var fi = this.row.grid.fieldinfo[fieldname];
$.extend(me.fields_dict[fieldname], fi);
}

this.toggle_add_delete_button_display(this.wrapper);

this.row.grid.open_grid_row = this;

this.set_focus();
},
make_form: function() {
if(!this.form_area) {
$(frappe.render_template("grid_form", {grid:this})).appendTo(this.wrapper);
this.form_area = this.wrapper.find(".form-area");
this.row.set_row_index();
this.set_form_events();
}
},
set_form_events: function() {
var me = this;
this.wrapper.find(".grid-delete-row")
.on('click', function() {
me.row.remove(); return false;
});
this.wrapper.find(".grid-insert-row")
.on('click', function() {
me.row.insert(true); return false;
});
this.wrapper.find(".grid-insert-row-below")
.on('click', function() {
me.row.insert(true, true); return false;
});
this.wrapper.find(".grid-append-row")
.on('click', function() {
me.row.toggle_view(false);
me.row.grid.add_new_row(me.row.doc.idx+1, null, true);
return false;
});
this.wrapper.find(".grid-form-heading, .grid-footer-toolbar").on("click", function() {
me.row.toggle_view();
return false;
});
},
toggle_add_delete_button_display: function($parent) {
$parent.find(".grid-header-toolbar .btn, .grid-footer-toolbar .btn")
.toggle(this.row.grid.is_editable());
},
refresh_field: function(fieldname) {
if(this.fields_dict[fieldname]) {
this.fields_dict[fieldname].refresh();
this.layout && this.layout.refresh_dependency();
}
},
set_focus: function() {
// wait for animation and then focus on the first row
var me = this;
setTimeout(function() {
if(me.row.frm && me.row.frm.doc.docstatus===0 || !me.row.frm) {
var first = me.form_area.find("input:first");
if(first.length && !in_list(["Date", "Datetime", "Time"], first.attr("data-fieldtype"))) {
try {
first.get(0).focus();
} catch(e) {
//
}
}
}
}, 500);
},
});

+ 83
- 31
frappe/public/js/frappe/form/layout.js View File

@@ -19,12 +19,14 @@ frappe.ui.form.Layout = Class.extend({
$.extend(this, opts); $.extend(this, opts);
}, },
make: function() { make: function() {
if(!this.parent && this.body)
if(!this.parent && this.body) {
this.parent = this.body; this.parent = this.body;
}
this.wrapper = $('<div class="form-layout">').appendTo(this.parent); this.wrapper = $('<div class="form-layout">').appendTo(this.parent);
this.message = $('<div class="form-message text-muted small hidden"></div>').appendTo(this.wrapper); this.message = $('<div class="form-message text-muted small hidden"></div>').appendTo(this.wrapper);
if(!this.fields)
if(!this.fields) {
this.fields = frappe.meta.sort_docfields(frappe.meta.docfield_map[this.doctype]); this.fields = frappe.meta.sort_docfields(frappe.meta.docfield_map[this.doctype]);
}
this.setup_tabbing(); this.setup_tabbing();
this.render(); this.render();
}, },
@@ -44,29 +46,58 @@ frappe.ui.form.Layout = Class.extend({
this.message.empty().addClass('hidden'); this.message.empty().addClass('hidden');
} }
}, },
render: function() {
render: function(new_fields) {
var me = this; var me = this;
var fields = new_fields || this.fields;


this.section = null; this.section = null;
this.column = null; this.column = null;
if((this.fields[0] && this.fields[0].fieldtype!="Section Break") || !this.fields.length) {

if (this.with_dashboard) {
this.setup_dashboard_section();
}

if (this.no_opening_section()) {
this.make_section(); this.make_section();
} }
$.each(this.fields, function(i, df) {
if(df.fieldtype === "Fold") {
me.make_page(df);
} else if (df.fieldtype === "Section Break") {
me.make_section(df);
} else if (df.fieldtype === "Column Break") {
me.make_column(df);
} else {
me.make_field(df);
$.each(fields, function(i, df) {
switch(df.fieldtype) {
case "Fold":
me.make_page(df);
break;
case "Section Break":
me.make_section(df);
break;
case "Column Break":
me.make_column(df);
break;
default:
me.make_field(df);
} }
}); });


}, },
make_field: function(df, colspan) {

no_opening_section: function() {
return (this.fields[0] && this.fields[0].fieldtype!="Section Break") || !this.fields.length;
},

setup_dashboard_section: function() {
if (this.no_opening_section()) {
this.fields.unshift({fieldtype: 'Section Break'});
}

this.fields.unshift({
fieldtype: 'Section Break',
fieldname: '_form_dashboard',
label: __('Dashboard'),
cssClass: 'form-dashboard',
collapsible: 1,
//hidden: 1
});
},

make_field: function(df, colspan, render = false) {
!this.section && this.make_section(); !this.section && this.make_section();
!this.column && this.make_column(); !this.column && this.make_column();


@@ -74,7 +105,8 @@ frappe.ui.form.Layout = Class.extend({
df: df, df: df,
doctype: this.doctype, doctype: this.doctype,
parent: this.column.wrapper.get(0), parent: this.column.wrapper.get(0),
frm: this.frm
frm: this.frm,
render_input: render
}); });


fieldobj.layout = this; fieldobj.layout = this;
@@ -171,13 +203,14 @@ frappe.ui.form.Layout = Class.extend({
var $this = $(this).removeClass("empty-section") var $this = $(this).removeClass("empty-section")
.removeClass("visible-section") .removeClass("visible-section")
.removeClass("shaded-section"); .removeClass("shaded-section");
if(!$(this).find(".frappe-control:not(.hide-control)").length) {
if(!$this.find(".frappe-control:not(.hide-control)").length
&& !$this.hasClass('form-dashboard')) {
// nothing visible, hide the section // nothing visible, hide the section
$(this).addClass("empty-section");
$this.addClass("empty-section");
} else { } else {
$(this).addClass("visible-section");
$this.addClass("visible-section");
if(cnt % 2) { if(cnt % 2) {
$(this).addClass("shaded-section");
$this.addClass("shaded-section");
} }
cnt ++; cnt ++;
} }
@@ -201,6 +234,10 @@ frappe.ui.form.Layout = Class.extend({
collapse = false; collapse = false;
} }


if(df.fieldname === '_form_dashboard') {
collapse = false;
}

section.collapse(collapse); section.collapse(collapse);
} }
} }
@@ -226,6 +263,18 @@ frappe.ui.form.Layout = Class.extend({
} }
}, },


refresh_fields: function(fields) {
let fieldnames = fields.map((field) => {
if(field.label) return field.label;
});

this.fields_list.map(fieldobj => {
if(fieldnames.includes(fieldobj._label)) {
fieldobj.refresh();
}
});
},

refresh_section_count: function() { refresh_section_count: function() {
this.wrapper.find(".section-count-label:visible").each(function(i) { this.wrapper.find(".section-count-label:visible").each(function(i) {
$(this).html(i+1); $(this).html(i+1);
@@ -241,7 +290,7 @@ frappe.ui.form.Layout = Class.extend({
if(doctype) if(doctype)
return me.handle_tab(doctype, fieldname, ev.shiftKey); return me.handle_tab(doctype, fieldname, ev.shiftKey);
} }
})
});
}, },
handle_tab: function(doctype, fieldname, shift) { handle_tab: function(doctype, fieldname, shift) {
var me = this, var me = this,
@@ -264,7 +313,7 @@ frappe.ui.form.Layout = Class.extend({
if(fields[i].df.fieldname==fieldname) { if(fields[i].df.fieldname==fieldname) {
if(shift) { if(shift) {
if(prev) { if(prev) {
this.set_focus(prev)
this.set_focus(prev);
} else { } else {
$(this.primary_button).focus(); $(this.primary_button).focus();
} }
@@ -290,7 +339,7 @@ frappe.ui.form.Layout = Class.extend({
// last row, close it and find next field // last row, close it and find next field
grid_row.toggle_view(false, function() { grid_row.toggle_view(false, function() {
grid_row.grid.frm.layout.handle_tab(grid_row.grid.df.parent, grid_row.grid.df.fieldname); grid_row.grid.frm.layout.handle_tab(grid_row.grid.df.parent, grid_row.grid.df.fieldname);
})
});
} else { } else {
// next row // next row
grid_row.grid.grid_rows[grid_row.doc.idx].toggle_view(true); grid_row.grid.grid_rows[grid_row.doc.idx].toggle_view(true);
@@ -325,7 +374,7 @@ frappe.ui.form.Layout = Class.extend({
} }
}, },
is_visible: function(field) { is_visible: function(field) {
return field.disp_status==="Write" && (field.$wrapper && field.$wrapper.is(":visible"))
return field.disp_status==="Write" && (field.$wrapper && field.$wrapper.is(":visible"));
}, },
set_focus: function(field) { set_focus: function(field) {
// next is table, show the table // next is table, show the table
@@ -450,17 +499,20 @@ frappe.ui.form.Section = Class.extend({
.appendTo(this.layout.page); .appendTo(this.layout.page);
this.layout.sections.push(this); this.layout.sections.push(this);


var section = this.wrapper[0];

if(this.df) { if(this.df) {
if(this.df.label) { if(this.df.label) {
this.make_head(); this.make_head();
} }
if(this.df.description) { if(this.df.description) {
$('<div class="col-sm-12 small text-muted form-section-description">' + __(this.df.description) + '</div>') $('<div class="col-sm-12 small text-muted form-section-description">' + __(this.df.description) + '</div>')
.appendTo(this.wrapper);
.appendTo(this.wrapper);
}
if(this.df.cssClass) {
this.wrapper.addClass(this.df.cssClass);
} }
} }


// for bc // for bc
this.body = $('<div class="section-body">').appendTo(this.wrapper); this.body = $('<div class="section-body">').appendTo(this.wrapper);
}, },
@@ -469,7 +521,7 @@ frappe.ui.form.Section = Class.extend({
if(!this.df.collapsible) { if(!this.df.collapsible) {
$('<div class="col-sm-12"><h6 class="form-section-heading uppercase">' $('<div class="col-sm-12"><h6 class="form-section-heading uppercase">'
+ __(this.df.label) + '</h6></div>') + __(this.df.label) + '</h6></div>')
.appendTo(this.wrapper);
.appendTo(this.wrapper);
} else { } else {
this.head = $('<div class="section-head"><a class="h6 uppercase">' this.head = $('<div class="section-head"><a class="h6 uppercase">'
+__(this.df.label)+'</a><span class="octicon octicon-chevron-down collapse-indicator"></span></div>').appendTo(this.wrapper); +__(this.df.label)+'</a><span class="octicon octicon-chevron-down collapse-indicator"></span></div>').appendTo(this.wrapper);
@@ -521,7 +573,7 @@ frappe.ui.form.Section = Class.extend({
} }
return missing_mandatory; return missing_mandatory;
} }
})
});


frappe.ui.form.Column = Class.extend({ frappe.ui.form.Column = Class.extend({
init: function(section, df) { init: function(section, df) {
@@ -538,7 +590,7 @@ frappe.ui.form.Column = Class.extend({
</form>\ </form>\
</div>').appendTo(this.section.body) </div>').appendTo(this.section.body)
.find("form") .find("form")
.on("submit", function() { return false; })
.on("submit", function() { return false; });


if(this.df.label) { if(this.df.label) {
$('<label class="control-label">'+ __(this.df.label) $('<label class="control-label">'+ __(this.df.label)
@@ -557,4 +609,4 @@ frappe.ui.form.Column = Class.extend({
refresh: function() { refresh: function() {
this.section.refresh(); this.section.refresh();
} }
})
});

+ 86
- 50
frappe/public/js/frappe/form/quick_entry.js View File

@@ -1,20 +1,37 @@
frappe.provide('frappe.ui.form'); frappe.provide('frappe.ui.form');


frappe.ui.form.make_quick_entry = (doctype, after_insert) => {
var trimmed_doctype = doctype.replace(/ /g, '');
var controller_name = "QuickEntryForm";

if(frappe.ui.form[trimmed_doctype + "QuickEntryForm"]){
controller_name = trimmed_doctype + "QuickEntryForm";
}

frappe.quick_entry = new frappe.ui.form[controller_name](doctype, after_insert);
return frappe.quick_entry.setup();
};

frappe.ui.form.QuickEntryForm = Class.extend({ frappe.ui.form.QuickEntryForm = Class.extend({
init: function(doctype, success_function){
init: function(doctype, after_insert){
this.doctype = doctype; this.doctype = doctype;
this.success_function = success_function;
this.setup();
this.after_insert = after_insert;
}, },


setup: function(){
var me = this;
frappe.model.with_doctype(this.doctype, function() {
me.set_meta_and_mandatory_fields();
var validate_flag = me.validate_quick_entry();
if(!validate_flag){
me.render_dialog();
}
setup: function() {
let me = this;
return new Promise(resolve => {
frappe.model.with_doctype(this.doctype, function() {
me.set_meta_and_mandatory_fields();
if(me.is_quick_entry()) {
me.render_dialog();
resolve(me);
} else {
frappe.quick_entry = null;
frappe.set_route('Form', me.doctype, me.doc.name)
.then(() => resolve(me));
}
});
}); });
}, },


@@ -25,34 +42,34 @@ frappe.ui.form.QuickEntryForm = Class.extend({
this.doc = frappe.model.get_new_doc(this.doctype, null, null, true); this.doc = frappe.model.get_new_doc(this.doctype, null, null, true);
}, },


validate_quick_entry: function(){
is_quick_entry: function(){
if(this.meta.quick_entry != 1) { if(this.meta.quick_entry != 1) {
frappe.set_route('Form', this.doctype, this.doc.name);
return true;
return false;
} }
var mandatory_flag = this.validate_mandatory_length();
var child_table_flag = this.validate_for_child_table();


if (mandatory_flag || child_table_flag){
return true;
if (this.too_many_mandatory_fields() || this.has_child_table()) {
return false;
} }

this.validate_for_prompt_autoname(); this.validate_for_prompt_autoname();
return true;
}, },


validate_mandatory_length: function(){
too_many_mandatory_fields: function(){
if(this.mandatory.length > 7) { if(this.mandatory.length > 7) {
// too many fields, show form // too many fields, show form
frappe.set_route('Form', this.doctype, this.doc.name);
return true; return true;
} }
return false;
}, },


validate_for_child_table: function(){
if($.map(this.mandatory, function(d) { return d.fieldtype==='Table' ? d : null; }).length) {
has_child_table: function(){
if($.map(this.mandatory, function(d) {
return d.fieldtype==='Table' ? d : null; }).length) {
// has mandatory table, quit! // has mandatory table, quit!
frappe.set_route('Form', this.doctype, this.doc.name);
return true; return true;
} }
return false;
}, },


validate_for_prompt_autoname: function(){ validate_for_prompt_autoname: function(){
@@ -86,6 +103,7 @@ frappe.ui.form.QuickEntryForm = Class.extend({
} }
}); });


this.dialog.onhide = () => frappe.quick_entry = null;
this.dialog.show(); this.dialog.show();
this.set_defaults(); this.set_defaults();
}, },
@@ -93,44 +111,62 @@ frappe.ui.form.QuickEntryForm = Class.extend({
register_primary_action: function(){ register_primary_action: function(){
var me = this; var me = this;
this.dialog.set_primary_action(__('Save'), function() { this.dialog.set_primary_action(__('Save'), function() {
if(me.dialog.working) return;
if(me.dialog.working) {
return;
}
var data = me.dialog.get_values(); var data = me.dialog.get_values();


if(data) { if(data) {
me.dialog.working = true; me.dialog.working = true;
var values = me.update_doc();
me.insert_document(values);
me.insert();
} }
}); });
}, },


insert_document: function(values){
var me = this;
frappe.call({
method: "frappe.client.insert",
args: {
doc: values
},
callback: function(r) {
me.dialog.hide();
// delete the old doc
frappe.model.clear_doc(me.dialog.doc.doctype, me.dialog.doc.name);
var doc = r.message;
if(me.success_function) {
me.success_function(doc);
}
frappe.ui.form.update_calling_link(doc.name);
},
error: function() {
me.open_doc();
},
always: function() {
me.dialog.working = false;
},
freeze: true
insert: function() {
let me = this;
return new Promise(resolve => {
me.update_doc();
frappe.call({
method: "frappe.client.insert",
args: {
doc: me.dialog.doc
},
callback: function(r) {
me.dialog.hide();
// delete the old doc
frappe.model.clear_doc(me.dialog.doc.doctype, me.dialog.doc.name);
me.dialog.doc = r.message;
if(frappe._from_link) {
frappe.ui.form.update_calling_link(me.dialog.doc);
} else {
if(me.after_insert) {
me.after_insert(me.dialig.doc);
} else {
me.open_from_if_not_list();
}
}
},
error: function() {
me.open_doc();
},
always: function() {
me.dialog.working = false;
resolve(me.dialog.doc);
},
freeze: true
});
}); });
}, },


open_from_if_not_list: function() {
let route = frappe.get_route();
let doc = this.dialog.doc;
if(route && !(route[0]==='List' && route[1]===doc.doctype)) {
frappe.set_route('Form', doc.doctype, doc.name);
}
},

update_doc: function(){ update_doc: function(){
var me = this; var me = this;
var data = this.dialog.get_values(true); var data = this.dialog.get_values(true);


+ 19
- 3
frappe/public/js/frappe/form/save.js View File

@@ -176,6 +176,8 @@ frappe.ui.form.save = function (frm, action, callback, btn) {
console.log("Already saving. Please wait a few moments.") console.log("Already saving. Please wait a few moments.")
throw "saving"; throw "saving";
} }

frappe.ui.form.remove_old_form_route();
frappe.ui.form.is_saving = true; frappe.ui.form.is_saving = true;


return frappe.call({ return frappe.call({
@@ -206,7 +208,18 @@ frappe.ui.form.save = function (frm, action, callback, btn) {
} }
} }


frappe.ui.form.update_calling_link = function (newdoc) {
frappe.ui.form.remove_old_form_route = () => {
let index = -1;
let current_route = frappe.get_route();
frappe.route_history.map((arr, i) => {
if (arr.join("/") === current_route.join("/")) {
index = i;
}
});
frappe.route_history.splice(index, 1);
}

frappe.ui.form.update_calling_link = (newdoc) => {
if (frappe._from_link && newdoc.doctype === frappe._from_link.df.options) { if (frappe._from_link && newdoc.doctype === frappe._from_link.df.options) {
var doc = frappe.get_doc(frappe._from_link.doctype, frappe._from_link.docname); var doc = frappe.get_doc(frappe._from_link.doctype, frappe._from_link.docname);
// set value // set value
@@ -226,8 +239,11 @@ frappe.ui.form.update_calling_link = function (newdoc) {


// if from form, switch // if from form, switch
if (frappe._from_link.frm) { if (frappe._from_link.frm) {
frappe.set_route("Form", frappe._from_link.frm.doctype, frappe._from_link.frm.docname);
setTimeout(function () { frappe.utils.scroll_to(frappe._from_link_scrollY); }, 100);
frappe.set_route("Form",
frappe._from_link.frm.doctype, frappe._from_link.frm.docname)
.then(() => {
frappe.utils.scroll_to(frappe._from_link_scrollY);
});
} }


frappe._from_link = null; frappe._from_link = null;


+ 67
- 15
frappe/public/js/frappe/form/script_manager.js View File

@@ -55,8 +55,8 @@ frappe.ui.form.off = function(doctype, fieldname, handler) {
} }




frappe.ui.form.trigger = function(doctype, fieldname, callback) {
cur_frm.script_manager.trigger(fieldname, doctype, null, callback);
frappe.ui.form.trigger = function(doctype, fieldname) {
cur_frm.script_manager.trigger(fieldname, doctype);
} }


frappe.ui.form.ScriptManager = Class.extend({ frappe.ui.form.ScriptManager = Class.extend({
@@ -64,32 +64,84 @@ frappe.ui.form.ScriptManager = Class.extend({
$.extend(this, opts); $.extend(this, opts);
}, },
make: function(ControllerClass) { make: function(ControllerClass) {
this.frm.cscript = $.extend(this.frm.cscript, new ControllerClass({frm: this.frm}));
this.frm.cscript = $.extend(this.frm.cscript,
new ControllerClass({frm: this.frm}));
}, },
trigger: function(event_name, doctype, name, callback) {
var me = this;
trigger: function(event_name, doctype, name) {
// trigger all the form level events that
// are bound to this event_name
let me = this;
doctype = doctype || this.frm.doctype; doctype = doctype || this.frm.doctype;
name = name || this.frm.docname; name = name || this.frm.docname;
var handlers = this.get_handlers(event_name, doctype, name, callback);
if(callback) handlers.push(callback);


let tasks = [];
let handlers = this.get_handlers(event_name, doctype);

// helper for child table
this.frm.selected_doc = frappe.get_doc(doctype, name); this.frm.selected_doc = frappe.get_doc(doctype, name);


return $.when.apply($, $.map(handlers, function(fn) { return fn(); }));
let runner = (_function, is_old_style) => {
let _promise = null;
if(is_old_style) {
// old style arguments (doc, cdt, cdn)
_promise = me.frm.cscript[_function](me.frm.doc, doctype, name);
} else {
// new style (frm, doctype, name)
_promise = _function(me.frm, doctype, name);
}

// if the trigger returns a promise, return it,
// or use the default promise frappe.after_ajax
if (_promise && _promise.then) {
return _promise;
} else {
return frappe.after_server_call();
}
};

// make list of functions to be run serially
handlers.new_style.forEach((_function) => {
if(event_name==='setup') {
// setup must be called immediately
runner(_function, false);
} else {
tasks.push(() => runner(_function, false));
}
});

handlers.old_style.forEach((_function) => {
if(event_name==='setup') {
// setup must be called immediately
runner(_function, true);
} else {
tasks.push(() => runner(_function, true));
}
});

// run them serially
return frappe.run_serially(tasks);
}, },
get_handlers: function(event_name, doctype, name, callback) {
var handlers = [];
var me = this;
has_handlers: function(event_name, doctype) {
let handlers = this.get_handlers(event_name, doctype);
return handlers && (handlers.old_style.length || handlers.new_style.length);
},
get_handlers: function(event_name, doctype) {
// returns list of all functions to be called (old style and new style)
let me = this;
let handlers = {
old_style: [],
new_style: []
};
if(frappe.ui.form.handlers[doctype] && frappe.ui.form.handlers[doctype][event_name]) { if(frappe.ui.form.handlers[doctype] && frappe.ui.form.handlers[doctype][event_name]) {
$.each(frappe.ui.form.handlers[doctype][event_name], function(i, fn) { $.each(frappe.ui.form.handlers[doctype][event_name], function(i, fn) {
handlers.push(function() { return fn(me.frm, doctype, name) });
handlers.new_style.push(fn);
}); });
} }
if(this.frm.cscript[event_name]) { if(this.frm.cscript[event_name]) {
handlers.push(function() { return me.frm.cscript[event_name](me.frm.doc, doctype, name); });
handlers.old_style.push(event_name);
} }
if(this.frm.cscript["custom_" + event_name]) { if(this.frm.cscript["custom_" + event_name]) {
handlers.push(function() { return me.frm.cscript["custom_" + event_name](me.frm.doc, doctype, name); });
handlers.old_style.push("custom_" + event_name);
} }
return handlers; return handlers;
}, },
@@ -105,7 +157,7 @@ frappe.ui.form.ScriptManager = Class.extend({


if(doctype.__custom_js) { if(doctype.__custom_js) {
try { try {
eval(doctype.__custom_js)
eval(doctype.__custom_js);
} catch(e) { } catch(e) {
frappe.msgprint({ frappe.msgprint({
title: __('Error in Custom Script'), title: __('Error in Custom Script'),


+ 1
- 3
frappe/public/js/frappe/form/templates/form_dashboard.html View File

@@ -1,6 +1,4 @@
<div class="form-dashboard hidden">
<h4 class="form-headline hidden form-dashboard-section">
</h4>
<div class="form-dashboard-wrapper">
<div class="progress-area hidden form-dashboard-section"> <div class="progress-area hidden form-dashboard-section">
</div> </div>
<div class="form-heatmap hidden form-dashboard-section"> <div class="form-heatmap hidden form-dashboard-section">


+ 1
- 2
frappe/public/js/frappe/form/templates/form_links.html View File

@@ -1,9 +1,8 @@
<div class="form-documents"> <div class="form-documents">
<h5 style="margin: 5px 0px;">{{__("Related Documents")}}</h5>
{% for (var i=0; i < transactions.length; i++) { %} {% for (var i=0; i < transactions.length; i++) { %}
{% if((i % 2)===0) { %}<div class="row">{% } %} {% if((i % 2)===0) { %}<div class="row">{% } %}
<div class="col-xs-6"> <div class="col-xs-6">
<h6 class="uppercase">{{ transactions[i].label }}</h5>
<h6>{{ transactions[i].label }}</h6>
{% for (var j=0; j < transactions[i].items.length; j++) { {% for (var j=0; j < transactions[i].items.length; j++) {
var doctype = transactions[i].items[j]; %} var doctype = transactions[i].items[j]; %}
<div class="document-link" <div class="document-link"


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

Loading…
Cancel
Save