diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000000..4754a63e7e --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,2 @@ +exclude_paths: + - '**.sql' \ No newline at end of file diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000000..c11c0ab6a3 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,2 @@ +disable=access-member-before-definition +disable=no-member \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index a5c81f9631..4e35a97208 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: python dist: trusty -sudo: required addons: hosts: @@ -54,6 +53,16 @@ matrix: env: DB=mariadb TYPE=server script: bench --site test_site run-tests --coverage --junit-xml-output frappe_unit_tests.xml +before_install: + # install wkhtmltopdf + - wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz + - tar -xf /tmp/wkhtmltox.tar.xz -C /tmp + - sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf + - sudo chmod o+x /usr/local/bin/wkhtmltopdf + + # install cups + - sudo apt-get install libcups2-dev + install: - cd ~ - source ./.nvm/nvm.sh @@ -67,23 +76,20 @@ install: - mkdir ~/frappe-bench/sites/test_site - cp $TRAVIS_BUILD_DIR/.travis/$DB.json ~/frappe-bench/sites/test_site/site_config.json - - mysql -u root -e "SET GLOBAL character_set_server = 'utf8mb4'" - - mysql -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'" + - if [ $DB == "mariadb" ];then + mysql -u root -e "SET GLOBAL character_set_server = 'utf8mb4'"; + mysql -u root -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"; + mysql -u root -e "CREATE DATABASE test_frappe"; + mysql -u root -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"; + mysql -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"; + mysql -u root -e "UPDATE mysql.user SET Password=PASSWORD('travis') WHERE User='root'"; + mysql -u root -e "FLUSH PRIVILEGES"; + fi - - mysql -u root -e "CREATE DATABASE test_frappe" - - mysql -u root -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'" - - mysql -u root -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'" - - - mysql -u root -e "UPDATE mysql.user SET Password=PASSWORD('travis') WHERE User='root'" - - mysql -u root -e "FLUSH PRIVILEGES" - - - psql -c "CREATE DATABASE test_frappe" -U postgres - - psql -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres - - - wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz - - tar -xf /tmp/wkhtmltox.tar.xz -C /tmp - - sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf - - sudo chmod o+x /usr/local/bin/wkhtmltopdf + - if [ $DB == "postgres" ];then + psql -c "CREATE DATABASE test_frappe" -U postgres; + psql -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres; + fi - cd ./frappe-bench diff --git a/cypress/integration/form.js b/cypress/integration/form.js index b7ddd6ecb7..5a8b85d19e 100644 --- a/cypress/integration/form.js +++ b/cypress/integration/form.js @@ -1,9 +1,15 @@ context('Form', () => { - before(() => { + beforeEach(() => { cy.login(); cy.visit('/desk'); }); - + before(() => { + cy.login(); + cy.visit('/desk'); + cy.window().its('frappe').then(frappe => { + frappe.call("frappe.tests.ui_test_helpers.create_contact_records"); + }); + }); it('create a new form', () => { cy.visit('/desk#Form/ToDo/New ToDo 1'); cy.fill_field('description', 'this is a test todo', 'Text Editor').blur(); @@ -13,4 +19,21 @@ context('Form', () => { cy.location('hash').should('eq', '#List/ToDo/List'); cy.get('.list-row').should('contain', 'this is a test todo'); }); + it('navigates between documents with child table list filters applied', () => { + cy.visit('/desk#List/Contact'); + cy.get('.tag-filters-area .btn:contains("Add Filter")').click(); + cy.get('.fieldname-select-area').should('exist'); + cy.get('.fieldname-select-area input').type('Number{enter}', { force: true }); + cy.get('.filter-field .input-with-feedback.form-control').type('123', { force: true }); + cy.get('.filter-box .btn:contains("Apply")').click({ force: true }); + cy.visit('/desk#Form/Contact/Test Form Contact 3'); + cy.get('.prev-doc').click(); + cy.get('.msgprint-dialog .modal-body').contains('No further records').should('be.visible'); + cy.get('.modal-backdrop').click(); + cy.get('.next-doc').click(); + cy.contains('Test Form Contact 2').should('not.exist'); + cy.get('.page-title .title-text').should('contain', 'Test Form Contact 1'); + cy.visit('/desk#List/Contact'); + cy.get('.clear-filters.btn').click(); + }); }); diff --git a/cypress/integration/grid_pagination.js b/cypress/integration/grid_pagination.js new file mode 100644 index 0000000000..67fdb8acf0 --- /dev/null +++ b/cypress/integration/grid_pagination.js @@ -0,0 +1,51 @@ +context('Grid Pagination', () => { + beforeEach(() => { + cy.login(); + cy.visit('/desk'); + }); + before(() => { + cy.login(); + cy.visit('/desk'); + cy.window().its('frappe').then(frappe => { + frappe.call("frappe.tests.ui_test_helpers.create_contact_phone_nos_records"); + }); + }); + it('creates pages for child table', () => { + cy.visit('/desk#Form/Contact/Test Contact'); + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); + cy.get('@table').find('.current-page-number').should('contain', '1'); + cy.get('@table').find('.total-page-number').should('contain', '20'); + cy.get('@table').find('.grid-body .grid-row').should('have.length', 50); + }); + it('goes to the next and previous page', () => { + cy.visit('/desk#Form/Contact/Test Contact'); + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); + cy.get('@table').find('.next-page').click(); + cy.get('@table').find('.current-page-number').should('contain', '2'); + cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '51'); + cy.get('@table').find('.prev-page').click(); + cy.get('@table').find('.current-page-number').should('contain', '1'); + cy.get('@table').find('.grid-body .grid-row').first().should('have.attr', 'data-idx', '1'); + }); + it('adds and deletes rows and changes page', ()=> { + cy.visit('/desk#Form/Contact/Test Contact'); + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); + cy.get('@table').find('button.grid-add-row').click(); + cy.get('@table').find('.grid-body .row-index').should('contain', 1001); + cy.get('@table').find('.current-page-number').should('contain', '21'); + cy.get('@table').find('.total-page-number').should('contain', '21'); + cy.get('@table').find('.grid-body .grid-row .grid-row-check').click({force: true}); + cy.get('@table').find('button.grid-remove-rows').click(); + cy.get('@table').find('.grid-body .row-index').last().should('contain', 1000); + cy.get('@table').find('.current-page-number').should('contain', '20'); + cy.get('@table').find('.total-page-number').should('contain', '20'); + }); + it('deletes all rows', ()=> { + cy.visit('/desk#Form/Contact/Test Contact'); + cy.get('.frappe-control[data-fieldname="phone_nos"]').as('table'); + cy.get('@table').find('.grid-heading-row .grid-row-check').click({force: true}); + cy.get('@table').find('button.grid-remove-all-rows').click(); + cy.get('.modal-dialog .btn-primary').contains('Yes').click(); + cy.get('@table').find('.grid-body .grid-row').should('have.length', 0); + }); +}); \ No newline at end of file diff --git a/cypress/integration/list_view.js b/cypress/integration/list_view.js index 20a973c1dd..d6627ea9c4 100644 --- a/cypress/integration/list_view.js +++ b/cypress/integration/list_view.js @@ -2,10 +2,9 @@ context('List View', () => { before(() => { cy.login(); cy.visit('/desk'); - cy.window().its('frappe').then(frappe => { - frappe.call("frappe.tests.ui_test_helpers.setup_workflow"); + return cy.window().its('frappe').then(frappe => { + return frappe.xcall("frappe.tests.ui_test_helpers.setup_workflow"); }); - cy.clear_cache(); }); it('enables "Actions" button', () => { const actions = ['Approve', 'Reject', 'Edit', 'Assign To', 'Apply Assignment Rule', 'Print', 'Delete']; diff --git a/frappe/__init__.py b/frappe/__init__.py index 1ee5db416b..cb1b6e5358 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -23,7 +23,7 @@ if sys.version[0] == '2': reload(sys) sys.setdefaultencoding("utf-8") -__version__ = '12.0.17' +__version__ = '12.0.20' __title__ = "Frappe Framework" local = Local() @@ -123,7 +123,6 @@ def init(site, sites_path=None, new_site=False): local.debug_log = [] local.realtime_log = [] local.flags = _dict({ - "ran_schedulers": [], "currently_saving": [], "redirect_location": "", "in_install_db": False, @@ -1508,7 +1507,22 @@ def logger(module=None, with_more_info=True): def log_error(message=None, title=None): '''Log error to Error Log''' - return get_doc(dict(doctype='Error Log', error=as_unicode(message or get_traceback()), + + # AI ALERT: + # the title and message may be swapped + # the better API for this is log_error(title, message), and used in many cases this way + # this hack tries to be smart about whats a title (single line ;-)) and fixes it + + if message: + if '\n' not in message: + title = message + error = get_traceback() + else: + error = message + else: + error = get_traceback() + + return get_doc(dict(doctype='Error Log', error=as_unicode(error), method=title)).insert(ignore_permissions=True) def get_desk_link(doctype, name): diff --git a/frappe/api.py b/frappe/api.py index b61c535edd..95a9a408a5 100644 --- a/frappe/api.py +++ b/frappe/api.py @@ -82,7 +82,7 @@ def handle(): if frappe.local.request.method=="PUT": if frappe.local.form_dict.data is None: - data = json.loads(frappe.local.request.get_data()) + data = json.loads(frappe.safe_decode(frappe.local.request.get_data())) else: data = json.loads(frappe.local.form_dict.data) doc = frappe.get_doc(doctype, name) @@ -117,7 +117,7 @@ def handle(): if frappe.local.request.method=="POST": if frappe.local.form_dict.data is None: - data = json.loads(frappe.local.request.get_data()) + data = json.loads(frappe.safe_decode(frappe.local.request.get_data())) else: data = json.loads(frappe.local.form_dict.data) data.update({ diff --git a/frappe/automation/doctype/auto_repeat/auto_repeat.py b/frappe/automation/doctype/auto_repeat/auto_repeat.py index a950669d12..27f17a1a62 100644 --- a/frappe/automation/doctype/auto_repeat/auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/auto_repeat.py @@ -105,10 +105,6 @@ class AutoRepeat(Document): schedule_details = [] start_date = getdate(self.start_date) end_date = getdate(self.end_date) - today = frappe.utils.datetime.date.today() - - if start_date < today: - start_date = today if not self.end_date: start_date = get_next_schedule_date(start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day) @@ -121,7 +117,8 @@ class AutoRepeat(Document): start_date = get_next_schedule_date(start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day) if self.end_date: - start_date = get_next_schedule_date(start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day) + start_date = start_date = get_next_schedule_date( + start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day, end_date, for_full_schedule=True) while (getdate(start_date) < getdate(end_date)): row = { "reference_document" : self.reference_document, @@ -129,7 +126,8 @@ class AutoRepeat(Document): "next_scheduled_date" : start_date } schedule_details.append(row) - start_date = get_next_schedule_date(start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day, end_date) + start_date = start_date = get_next_schedule_date( + start_date, self.frequency, self.repeat_on_day, self.repeat_on_last_day, end_date, for_full_schedule=True) return schedule_details @@ -271,18 +269,28 @@ class AutoRepeat(Document): ) -def get_next_schedule_date(start_date, frequency, repeat_on_day, repeat_on_last_day = False, end_date = None): +def get_next_schedule_date(start_date, frequency, repeat_on_day, repeat_on_last_day=False, end_date=None, for_full_schedule=False): month_count = month_map.get(frequency) + day_count = 0 if month_count and repeat_on_last_day: next_date = get_next_date(start_date, month_count, 31) + day_count = 31 + next_date = get_next_date(start_date, month_count, day_count) elif month_count and repeat_on_day: next_date = get_next_date(start_date, month_count, repeat_on_day) + day_count = repeat_on_day + next_date = get_next_date(start_date, month_count, day_count) elif month_count: next_date = get_next_date(start_date, month_count) else: days = 7 if frequency == 'Weekly' else 1 next_date = add_days(start_date, days) + # next schedule date should be after or on current date + if not for_full_schedule: + while getdate(next_date) < getdate(today()): + next_date = get_next_date(next_date, month_count, day_count) + return next_date def get_next_date(dt, mcount, day=None): @@ -307,7 +315,7 @@ def create_repeated_entries(data): current_date = getdate(today()) schedule_date = getdate(doc.next_schedule_date) - while schedule_date <= current_date and not doc.disabled: + if schedule_date == current_date and not doc.disabled: doc.create_documents() schedule_date = get_next_schedule_date(schedule_date, doc.frequency, doc.repeat_on_day, doc.repeat_on_last_day, doc.end_date) diff --git a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py index cb98d4a8fd..95f95f3e7d 100644 --- a/frappe/automation/doctype/auto_repeat/test_auto_repeat.py +++ b/frappe/automation/doctype/auto_repeat/test_auto_repeat.py @@ -96,6 +96,21 @@ class TestAutoRepeat(unittest.TestCase): linked_comm = frappe.db.exists("Communication", dict(reference_doctype="ToDo", reference_name=new_todo)) self.assertTrue(linked_comm) + def test_next_schedule_date(self): + current_date = getdate(today()) + todo = frappe.get_doc( + dict(doctype='ToDo', description='test next schedule date todo', assigned_by='Administrator')).insert() + doc = make_auto_repeat(frequency='Monthly', reference_document=todo.name, start_date=add_months(today(), -2)) + + #check next_schedule_date is set as per current date + #it should not be a previous month's date + self.assertEqual(doc.next_schedule_date, current_date) + data = get_auto_repeat_entries(current_date) + create_repeated_entries(data) + docnames = frappe.get_all(doc.reference_doctype, {'auto_repeat': doc.name}) + #the original doc + the repeated doc + self.assertEqual(len(docnames), 2) + def make_auto_repeat(**args): args = frappe._dict(args) diff --git a/frappe/commands/site.py b/frappe/commands/site.py index e28fd36346..89e9ab7f34 100755 --- a/frappe/commands/site.py +++ b/frappe/commands/site.py @@ -13,6 +13,8 @@ from six import text_type @click.argument('site') @click.option('--db-name', help='Database name') @click.option('--db-type', default='mariadb', type=click.Choice(['mariadb', 'postgres']), help='Optional "postgres" or "mariadb". Default is "mariadb"') +@click.option('--db-host', help='Database Host') +@click.option('--db-port', type=int, help='Database Port') @click.option('--mariadb-root-username', default='root', help='Root username for MariaDB') @click.option('--mariadb-root-password', help='Root password for MariaDB') @click.option('--admin-password', help='Administrator password for new site', default=None) @@ -21,22 +23,22 @@ from six import text_type @click.option('--source_sql', help='Initiate database with a SQL file') @click.option('--install-app', multiple=True, help='Install app after installation') def new_site(site, mariadb_root_username=None, mariadb_root_password=None, admin_password=None, - verbose=False, install_apps=None, source_sql=None, force=None, install_app=None, - db_name=None, db_type=None): + verbose=False, install_apps=None, source_sql=None, force=None, install_app=None, + db_name=None, db_type=None, db_host=None, db_port=None): "Create a new site" frappe.init(site=site, new_site=True) _new_site(db_name, site, mariadb_root_username=mariadb_root_username, - mariadb_root_password=mariadb_root_password, admin_password=admin_password, - verbose=verbose, install_apps=install_app, source_sql=source_sql, force=force, - db_type=db_type) + mariadb_root_password=mariadb_root_password, admin_password=admin_password, + verbose=verbose, install_apps=install_app, source_sql=source_sql, force=force, + db_type=db_type, db_host=db_host, db_port=db_port) if len(frappe.utils.get_sites()) == 1: use(site) def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=None, - admin_password=None, verbose=False, install_apps=None, source_sql=None, force=False, - reinstall=False, db_type=None): + admin_password=None, verbose=False, install_apps=None, source_sql=None, force=False, + reinstall=False, db_type=None, db_host=None, db_port=None): """Install a new Frappe site""" if not force and os.path.exists(site): @@ -65,8 +67,8 @@ def _new_site(db_name, site, mariadb_root_username=None, mariadb_root_password=N installing = touch_file(get_site_path('locks', 'installing.lock')) install_db(root_login=mariadb_root_username, root_password=mariadb_root_password, - db_name=db_name, admin_password=admin_password, verbose=verbose, - source_sql=source_sql, force=force, reinstall=reinstall, db_type=db_type) + db_name=db_name, admin_password=admin_password, verbose=verbose, + source_sql=source_sql, force=force, reinstall=reinstall, db_type=db_type, db_host=db_host, db_port=db_port) apps_to_install = ['frappe'] + (frappe.conf.get("install_apps") or []) + (list(install_apps) or []) for app in apps_to_install: diff --git a/frappe/commands/utils.py b/frappe/commands/utils.py index 9a408430e7..d29f0a9c97 100644 --- a/frappe/commands/utils.py +++ b/frappe/commands/utils.py @@ -507,26 +507,6 @@ def run_ui_tests(context, app, headless=False): formatted_command = command.format(site_env=site_env, password_env=password_env, run_or_open=run_or_open) frappe.commands.popen(formatted_command, cwd=app_base_path, raise_err=True) -@click.command('run-setup-wizard-ui-test') -@click.option('--app', help="App to run tests on, leave blank for all apps") -@click.option('--profile', is_flag=True, default=False) -@pass_context -def run_setup_wizard_ui_test(context, app=None, profile=False): - "Run setup wizard UI test" - import frappe.test_runner - - site = get_site(context) - frappe.init(site=site) - frappe.connect() - - ret = frappe.test_runner.run_setup_wizard_ui_test(app=app, verbose=context.verbose, - profile=profile) - if len(ret.failures) == 0 and len(ret.errors) == 0: - ret = 0 - - if os.environ.get('CI'): - sys.exit(ret) - @click.command('serve') @click.option('--port', default=8000) @click.option('--profile', is_flag=True, default=False) @@ -752,7 +732,6 @@ commands = [ reset_perms, run_tests, run_ui_tests, - run_setup_wizard_ui_test, serve, set_config, show_config, diff --git a/frappe/config/settings.py b/frappe/config/settings.py index 2422f2fae2..a0a7dcd65f 100644 --- a/frappe/config/settings.py +++ b/frappe/config/settings.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +import frappe from frappe import _ from frappe.desk.moduleview import add_setup_section @@ -88,7 +89,7 @@ def get_data(): ] }, { - "label": _("Email"), + "label": _("Email / Notifications"), "icon": "fa fa-envelope", "items": [ { @@ -120,6 +121,12 @@ def get_data(): "type": "doctype", "name": "Newsletter", "description": _("Create and manage newsletter") + }, + { + "type": "doctype", + "route": "Form/Notification Settings/{}".format(frappe.session.user), + "name": "Notification Settings", + "description": _("Configure notifications for mentions, assignments, energy points and more.") } ] }, diff --git a/frappe/contacts/doctype/address/address.py b/frappe/contacts/doctype/address/address.py index 21ed88addb..75fd0ad8c6 100644 --- a/frappe/contacts/doctype/address/address.py +++ b/frappe/contacts/doctype/address/address.py @@ -60,10 +60,6 @@ class Address(Document): if not [row for row in self.links if row.link_doctype == "Company"]: frappe.throw(_("Company is mandatory, as it is your company address")) - # removing other links - to_remove = [row for row in self.links if row.link_doctype != "Company"] - [ self.remove(row) for row in to_remove ] - def get_display(self): return get_address_display(self.as_dict()) diff --git a/frappe/core/doctype/communication/email.py b/frappe/core/doctype/communication/email.py index a285941c68..1848136bee 100755 --- a/frappe/core/doctype/communication/email.py +++ b/frappe/core/doctype/communication/email.py @@ -10,7 +10,6 @@ from email.utils import formataddr from frappe.core.utils import get_parent_doc from frappe.utils import (get_url, get_formatted_email, cint, validate_email_address, split_emails, time_diff_in_seconds, parse_addr, get_datetime) -from frappe.utils.scheduler import log from frappe.email.email_body import get_message_id import frappe.email.smtp import time @@ -399,7 +398,7 @@ def get_bcc(doc, recipients=None, fetched_from_email_account=False): return bcc def add_attachments(name, attachments): - '''Add attachments to the given Communiction''' + '''Add attachments to the given Communication''' # loop through attachments for a in attachments: if isinstance(a, string_types): @@ -412,7 +411,9 @@ def add_attachments(name, attachments): "file_url": attach.file_url, "attached_to_doctype": "Communication", "attached_to_name": name, - "folder": "Home/Attachments"}) + "folder": "Home/Attachments", + "is_private": attach.is_private + }) _file.save(ignore_permissions=True) def filter_email_list(doc, email_list, exclude, is_cc=False, is_bcc=False): @@ -509,17 +510,7 @@ def sendmail(communication_name, print_html=None, print_format=None, attachments break except: - traceback = log("frappe.core.doctype.communication.email.sendmail", frappe.as_json({ - "communication_name": communication_name, - "print_html": print_html, - "print_format": print_format, - "attachments": attachments, - "recipients": recipients, - "cc": cc, - "bcc": bcc, - "lang": lang - })) - frappe.logger(__name__).error(traceback) + traceback = frappe.log_error("frappe.core.doctype.communication.email.sendmail") raise def update_mins_to_first_communication(parent, communication): diff --git a/frappe/core/doctype/data_import/importer_new.py b/frappe/core/doctype/data_import/importer_new.py index 6fccbc89ef..f14f38c56d 100644 --- a/frappe/core/doctype/data_import/importer_new.py +++ b/frappe/core/doctype/data_import/importer_new.py @@ -54,8 +54,10 @@ class Importer: extension = None if self.data_import and self.data_import.import_file: file_doc = frappe.get_doc("File", {"file_url": self.data_import.import_file}) + parts = file_doc.get_extension() + extension = parts[1] content = file_doc.get_content() - extension = file_doc.file_name.split(".")[1] + extension = extension.lstrip(".") if file_path: content, extension = self.read_file(file_path) @@ -79,6 +81,12 @@ class Importer: return file_content, extn def read_content(self, content, extension): + error_title = _("Template Error") + if extension not in ("csv", "xlsx", "xls"): + frappe.throw( + _("Import template should be of type .csv, .xlsx or .xls"), title=error_title + ) + if extension == "csv": data = read_csv_content(content) elif extension == "xlsx": @@ -86,6 +94,11 @@ class Importer: elif extension == "xls": data = read_xls_file_from_attached_file(content) + if len(data) <= 1: + frappe.throw( + _("Import template should contain a Header and atleast one row."), title=error_title + ) + self.header_row = data[0] self.data = data[1:] @@ -862,15 +875,15 @@ class Importer: if failed_records: print("Failed to import {0} records".format(len(failed_records))) - file_name = '{0}_import_on_{1}.txt'.format(self.doctype, frappe.utils.now()) - print('Check {0} for errors'.format(os.path.join('sites', file_name))) + file_name = "{0}_import_on_{1}.txt".format(self.doctype, frappe.utils.now()) + print("Check {0} for errors".format(os.path.join("sites", file_name))) text = "" for w in failed_records: - text += "Row Indexes: {0}\n".format(str(w.get('row_indexes', []))) - text += "Messages:\n{0}\n".format('\n'.join(w.get('messages', []))) - text += "Traceback:\n{0}\n\n".format(w.get('exception')) + text += "Row Indexes: {0}\n".format(str(w.get("row_indexes", []))) + text += "Messages:\n{0}\n".format("\n".join(w.get("messages", []))) + text += "Traceback:\n{0}\n\n".format(w.get("exception")) - with open(file_name, 'w') as f: + with open(file_name, "w") as f: f.write(text) diff --git a/frappe/core/doctype/doctype/doctype.json b/frappe/core/doctype/doctype/doctype.json index a6a5e258ee..4e3f2fd84a 100644 --- a/frappe/core/doctype/doctype/doctype.json +++ b/frappe/core/doctype/doctype/doctype.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_rename": 1, "autoname": "Prompt", "creation": "2013-02-18 13:36:19", @@ -28,6 +29,7 @@ "name_case", "column_break_15", "description", + "documentation", "form_settings_section", "image_field", "timeline_field", @@ -57,6 +59,10 @@ "restrict_to_domain", "read_only", "in_create", + "actions_section", + "actions", + "links_section", + "links", "web_view", "has_web_view", "allow_guest_to_view", @@ -454,11 +460,39 @@ "fieldname": "nsm_parent_field", "fieldtype": "Data", "label": "Parent Field (Tree)" + }, + { + "description": "URL for documentation or help", + "fieldname": "documentation", + "fieldtype": "Data", + "label": "Documentation Link" + }, + { + "fieldname": "actions_section", + "fieldtype": "Section Break", + "label": "Actions" + }, + { + "fieldname": "actions", + "fieldtype": "Table", + "label": "Actions", + "options": "DocType Action" + }, + { + "fieldname": "links_section", + "fieldtype": "Section Break", + "label": "Links Section" + }, + { + "fieldname": "links", + "fieldtype": "Table", + "label": "Links", + "options": "DocType Link" } ], "icon": "fa fa-bolt", "idx": 6, - "modified": "2019-09-07 14:28:05.392490", + "modified": "2019-11-25 17:24:03.690192", "modified_by": "Administrator", "module": "Core", "name": "DocType", diff --git a/frappe/core/doctype/test_runner/__init__.py b/frappe/core/doctype/doctype_action/__init__.py similarity index 100% rename from frappe/core/doctype/test_runner/__init__.py rename to frappe/core/doctype/doctype_action/__init__.py diff --git a/frappe/core/doctype/doctype_action/doctype_action.json b/frappe/core/doctype/doctype_action/doctype_action.json new file mode 100644 index 0000000000..7a1b845af3 --- /dev/null +++ b/frappe/core/doctype/doctype_action/doctype_action.json @@ -0,0 +1,57 @@ +{ + "actions": [], + "creation": "2019-09-23 16:28:13.953520", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label", + "action_type", + "action", + "group" + ], + "fields": [ + { + "columns": 2, + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1 + }, + { + "fieldname": "group", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Group" + }, + { + "columns": 2, + "fieldname": "action_type", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Action Type", + "options": "Server Action", + "reqd": 1 + }, + { + "columns": 4, + "fieldname": "action", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Action", + "reqd": 1 + } + ], + "istable": 1, + "modified": "2019-09-24 09:11:39.860100", + "modified_by": "Administrator", + "module": "Core", + "name": "DocType Action", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/doctype_action/doctype_action.py b/frappe/core/doctype/doctype_action/doctype_action.py new file mode 100644 index 0000000000..a745c7da40 --- /dev/null +++ b/frappe/core/doctype/doctype_action/doctype_action.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class DocTypeAction(Document): + pass diff --git a/frappe/tests/ui/__init__.py b/frappe/core/doctype/doctype_link/__init__.py similarity index 100% rename from frappe/tests/ui/__init__.py rename to frappe/core/doctype/doctype_link/__init__.py diff --git a/frappe/core/doctype/doctype_link/doctype_link.json b/frappe/core/doctype/doctype_link/doctype_link.json new file mode 100644 index 0000000000..752b4bb5da --- /dev/null +++ b/frappe/core/doctype/doctype_link/doctype_link.json @@ -0,0 +1,46 @@ +{ + "actions": [], + "creation": "2019-09-24 11:41:25.291377", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "link_doctype", + "link_fieldname", + "group" + ], + "fields": [ + { + "fieldname": "link_doctype", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Link DocType", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "link_fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Link Fieldname", + "reqd": 1 + }, + { + "fieldname": "group", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Group" + } + ], + "istable": 1, + "modified": "2019-09-24 11:41:25.291377", + "modified_by": "Administrator", + "module": "Core", + "name": "DocType Link", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/doctype_link/doctype_link.py b/frappe/core/doctype/doctype_link/doctype_link.py new file mode 100644 index 0000000000..efe8b09809 --- /dev/null +++ b/frappe/core/doctype/doctype_link/doctype_link.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class DocTypeLink(Document): + pass diff --git a/frappe/core/doctype/file/file.py b/frappe/core/doctype/file/file.py index ee139000e1..041b8c3011 100755 --- a/frappe/core/doctype/file/file.py +++ b/frappe/core/doctype/file/file.py @@ -89,8 +89,9 @@ class File(Document): def validate(self): if self.is_new(): + self.set_is_private() + self.set_file_name() self.validate_duplicate_entry() - self.validate_file_name() self.validate_folder() if not self.file_url and not self.flags.ignore_file_validate: @@ -133,6 +134,9 @@ class File(Document): frappe.db.set_value(self.attached_to_doctype, self.attached_to_name, self.attached_to_field, self.file_url) + if self.file_url and (self.is_private != self.file_url.startswith('/private')): + frappe.throw(_('Invalid file URL. Please contact System Administrator.')) + def set_folder_name(self): """Make parent folders if not exists based on reference doctype and name""" if self.attached_to_doctype and not self.folder: @@ -157,9 +161,11 @@ class File(Document): def validate_duplicate_entry(self): if not self.flags.ignore_duplicate_entry_error and not self.is_folder: - # check duplicate name + if not self.content_hash: + self.generate_content_hash() - # check duplicate assignement + # check duplicate name + # check duplicate assignment filters = { 'content_hash': self.content_hash, 'is_private': self.is_private, @@ -184,21 +190,20 @@ class File(Document): else: self.file_url = duplicate_file.file_url - def validate_file_name(self): + def set_file_name(self): if not self.file_name and self.file_url: self.file_name = self.file_url.split('/')[-1] def generate_content_hash(self): - if self.content_hash or not self.file_url: + if self.content_hash or not self.file_url or self.file_url.startswith('http'): return - if self.file_url.startswith("/files/"): - try: - with open(get_files_path(self.file_name.lstrip("/")), "rb") as f: - self.content_hash = get_content_hash(f.read()) - except IOError: - frappe.msgprint(_("File {0} does not exist").format(self.file_url)) - raise + try: + with open(get_files_path(self.file_name.lstrip("/"), is_private=self.is_private), "rb") as f: + self.content_hash = get_content_hash(f.read()) + except IOError: + frappe.msgprint(_("File {0} does not exist").format(self.file_url)) + raise def on_trash(self): if self.is_home_folder or self.is_attachments_folder: @@ -563,6 +568,9 @@ class File(Document): except frappe.DoesNotExistError: frappe.clear_messages() + def set_is_private(self): + if self.file_url: + self.is_private = cint(self.file_url.startswith('/private')) def on_doctype_update(): frappe.db.add_index("File", ["attached_to_doctype", "attached_to_name"]) diff --git a/frappe/core/doctype/prepared_report/prepared_report.py b/frappe/core/doctype/prepared_report/prepared_report.py index 071bb4afc0..bc9a1fcdcd 100644 --- a/frappe/core/doctype/prepared_report/prepared_report.py +++ b/frappe/core/doctype/prepared_report/prepared_report.py @@ -25,7 +25,7 @@ class PreparedReport(Document): enqueue(run_background, prepared_report=self.name, timeout=6000) def on_trash(self): - remove_all("PreparedReport", self.name, from_delete=True) + remove_all("Prepared Report", self.name) def run_background(prepared_report): @@ -85,7 +85,8 @@ def create_json_gz_file(data, dt, dn): "file_name": json_filename, "attached_to_doctype": dt, "attached_to_name": dn, - "content": compressed_content + "content": compressed_content, + "is_private": 1 }) _file.save(ignore_permissions=True) diff --git a/frappe/core/doctype/scheduled_job_log/__init__.py b/frappe/core/doctype/scheduled_job_log/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.js b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.js new file mode 100644 index 0000000000..d43160c658 --- /dev/null +++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.js @@ -0,0 +1,8 @@ +// Copyright (c) 2019, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Scheduled Job Log', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json new file mode 100644 index 0000000000..9e7f72a722 --- /dev/null +++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.json @@ -0,0 +1,64 @@ +{ + "actions": [], + "creation": "2019-09-23 14:36:36.935869", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "status", + "scheduled_job_type", + "details" + ], + "fields": [ + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Scheduled\nSuccess\nFailed", + "read_only": 1, + "reqd": 1 + }, + { + "fieldname": "details", + "fieldtype": "Code", + "label": "Details", + "read_only": 1 + }, + { + "fieldname": "scheduled_job_type", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Scheduled Job", + "options": "Scheduled Job Type", + "read_only": 1, + "reqd": 1 + } + ], + "links": [], + "modified": "2019-09-25 11:55:10.646458", + "modified_by": "Administrator", + "module": "Core", + "name": "Scheduled Job Log", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py new file mode 100644 index 0000000000..26871c9adf --- /dev/null +++ b/frappe/core/doctype/scheduled_job_log/scheduled_job_log.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class ScheduledJobLog(Document): + pass diff --git a/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py b/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py new file mode 100644 index 0000000000..1e5290425b --- /dev/null +++ b/frappe/core/doctype/scheduled_job_log/test_scheduled_job_log.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestScheduledJobLog(unittest.TestCase): + pass diff --git a/frappe/core/doctype/scheduled_job_type/__init__.py b/frappe/core/doctype/scheduled_job_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.js b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.js new file mode 100644 index 0000000000..55907b17fc --- /dev/null +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.js @@ -0,0 +1,8 @@ +// Copyright (c) 2019, Frappe Technologies and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Scheduled Job Type', { + // refresh: function(frm) { + + // } +}); diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json new file mode 100644 index 0000000000..e2ec921679 --- /dev/null +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.json @@ -0,0 +1,98 @@ +{ + "actions": [ + { + "action": "frappe.core.doctype.scheduled_job_type.scheduled_job_type.execute_event", + "action_type": "Server Action", + "label": "Execute" + } + ], + "creation": "2019-09-23 14:34:09.205368", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "stopped", + "method", + "frequency", + "cron_format", + "last_execution", + "create_log" + ], + "fields": [ + { + "fieldname": "method", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Method", + "read_only": 1, + "reqd": 1 + }, + { + "default": "0", + "fieldname": "stopped", + "fieldtype": "Check", + "label": "Stopped" + }, + { + "default": "0", + "depends_on": "eval:doc.queue==='All'", + "fieldname": "create_log", + "fieldtype": "Check", + "label": "Create Log" + }, + { + "fieldname": "last_execution", + "fieldtype": "Datetime", + "label": "Last Execution", + "read_only": 1 + }, + { + "allow_in_quick_entry": 1, + "depends_on": "eval:doc.queue==='Cron'", + "fieldname": "cron_format", + "fieldtype": "Data", + "label": "Cron Format", + "read_only": 1 + }, + { + "fieldname": "frequency", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Frequency", + "options": "All\nHourly\nHourly Long\nDaily\nDaily Long\nWeekly\nWeekly Long\nMonthly\nMonthly Long\nCron\nYearly\nAnnual", + "read_only": 1, + "reqd": 1 + } + ], + "in_create": 1, + "links": [ + { + "link_doctype": "Scheduled Job Log", + "link_fieldname": "scheduled_job_type" + } + ], + "modified": "2019-12-09 11:10:21.259929", + "modified_by": "Administrator", + "module": "Core", + "name": "Scheduled Job Type", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py new file mode 100644 index 0000000000..5d5bd35eec --- /dev/null +++ b/frappe/core/doctype/scheduled_job_type/scheduled_job_type.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals + +import frappe, json +from frappe.model.document import Document +from frappe.utils import now_datetime, get_datetime +from datetime import datetime +from croniter import croniter +from frappe.utils.background_jobs import enqueue, get_jobs + +class ScheduledJobType(Document): + def autoname(self): + self.name = '.'.join(self.method.split('.')[-2:]) + + def validate(self): + if self.frequency != 'All': + # force logging for all events other than continuous ones (ALL) + self.create_log = 1 + + def enqueue(self): + # enqueue event if last execution is done + if self.is_event_due(): + if frappe.flags.enqueued_jobs: + frappe.flags.enqueued_jobs.append(self.method) + + if frappe.flags.execute_job: + self.execute() + else: + if not self.is_job_in_queue(): + enqueue('frappe.core.doctype.scheduled_job_type.scheduled_job_type.run_scheduled_job', + queue = self.get_queue_name(), job_type=self.method) + return True + + return False + + def is_event_due(self, current_time = None): + '''Return true if event is due based on time lapsed since last execution''' + # if the next scheduled event is before NOW, then its due! + return self.get_next_execution() <= (current_time or now_datetime()) + + def is_job_in_queue(self): + queued_jobs = get_jobs(site=frappe.local.site, key='job_type')[frappe.local.site] + return self.method in queued_jobs + + def get_next_execution(self): + CRON_MAP = { + "Yearly": "0 0 1 1 *", + "Annual": "0 0 1 1 *", + "Monthly": "0 0 1 * *", + "Monthly Long": "0 0 1 * *", + "Weekly": "0 0 * * 0", + "Weekly Long": "0 0 * * 0", + "Daily": "0 0 * * *", + "Daily Long": "0 0 * * *", + "Hourly": "0 * * * *", + "Hourly Long": "0 * * * *", + "All": "0/" + str((frappe.get_conf().scheduler_interval or 240) // 60) + " * * * *", + } + + if not self.cron_format: + self.cron_format = CRON_MAP[self.frequency] + + return croniter(self.cron_format, + get_datetime(self.last_execution or datetime(2000, 1, 1))).get_next(datetime) + + def execute(self): + self.scheduler_log = None + try: + self.log_status('Start') + frappe.get_attr(self.method)() + frappe.db.commit() + self.log_status('Complete') + except Exception: + frappe.db.rollback() + self.log_status('Failed') + + def log_status(self, status): + # log file + frappe.logger(__name__).info('Scheduled Job {0}: {1} for {2}'.format(status, self.method, frappe.local.site)) + self.update_scheduler_log(status) + + def update_scheduler_log(self, status): + if not self.create_log: + return + if not self.scheduler_log: + self.scheduler_log = frappe.get_doc(dict(doctype = 'Scheduled Job Log', scheduled_job_type=self.name)).insert(ignore_permissions=True) + self.scheduler_log.db_set('status', status) + if status == 'Failed': + self.scheduler_log.db_set('details', frappe.get_traceback()) + if status == 'Start': + self.db_set('last_execution', now_datetime(), update_modified=False) + frappe.db.commit() + + def get_queue_name(self): + return 'long' if ('Long' in self.frequency) else 'default' + + def on_trash(self): + frappe.db.sql('delete from `tabScheduled Job Log` where scheduled_job_type=%s', self.name) + +@frappe.whitelist() +def execute_event(doc): + frappe.only_for('System Manager') + doc = json.loads(doc) + frappe.get_doc('Scheduled Job Type', doc.get('name')).enqueue() + +def run_scheduled_job(job_type): + '''This is a wrapper function that runs a hooks.scheduler_events method''' + try: + frappe.get_doc('Scheduled Job Type', dict(method=job_type)).execute() + except Exception: + print(frappe.get_traceback()) + +def sync_jobs(): + frappe.reload_doc('core', 'doctype', 'scheduled_job_type') + all_events = [] + scheduler_events = frappe.get_hooks("scheduler_events") + insert_events(all_events, scheduler_events) + clear_events(all_events, scheduler_events) + +def insert_events(all_events, scheduler_events): + for event_type in scheduler_events: + events = scheduler_events.get(event_type) + if isinstance(events, dict): + insert_cron_event(events, all_events) + else: + # hourly, daily etc + insert_event_list(events, event_type, all_events) + +def insert_cron_event(events, all_events): + for cron_format in events: + for event in events.get(cron_format): + all_events.append(event) + insert_single_event('Cron', event, cron_format) + +def insert_event_list(events, event_type, all_events): + for event in events: + all_events.append(event) + frequency = event_type.replace('_', ' ').title() + insert_single_event(frequency, event) + +def insert_single_event(frequency, event, cron_format = None): + if not frappe.db.exists('Scheduled Job Type', dict(method=event)): + frappe.get_doc(dict( + doctype = 'Scheduled Job Type', + method = event, + cron_format = cron_format, + frequency = frequency + )).insert() + +def clear_events(all_events, scheduler_events): + for event in frappe.get_all('Scheduled Job Type', ('name', 'method')): + if event.method not in all_events: + frappe.delete_doc('Scheduled Job Type', event.name) diff --git a/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py new file mode 100644 index 0000000000..ec1e70ad6a --- /dev/null +++ b/frappe/core/doctype/scheduled_job_type/test_scheduled_job_type.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2019, Frappe Technologies and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest +from frappe.utils import get_datetime + +from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs + +class TestScheduledJobType(unittest.TestCase): + def setUp(self): + if not frappe.get_all('Scheduled Job Type', limit=1): + frappe.db.rollback() + frappe.db.sql('truncate `tabScheduled Job Type`') + sync_jobs() + frappe.db.commit() + + def test_sync_jobs(self): + all_job = frappe.get_doc('Scheduled Job Type', + dict(method='frappe.email.queue.flush')) + self.assertEqual(all_job.frequency, 'All') + + daily_job = frappe.get_doc('Scheduled Job Type', + dict(method='frappe.email.queue.clear_outbox')) + self.assertEqual(daily_job.frequency, 'Daily') + + # check if cron jobs are synced + cron_job = frappe.get_doc('Scheduled Job Type', + dict(method='frappe.oauth.delete_oauth2_data')) + self.assertEqual(cron_job.frequency, 'Cron') + self.assertEqual(cron_job.cron_format, '0/15 * * * *') + + def test_daily_job(self): + job = frappe.get_doc('Scheduled Job Type', dict(method = 'frappe.email.queue.clear_outbox')) + job.db_set('last_execution', '2019-01-01 00:00:00') + self.assertTrue(job.is_event_due(get_datetime('2019-01-02 00:00:06'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-01 00:00:06'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-01 23:59:59'))) + + def test_weekly_job(self): + job = frappe.get_doc('Scheduled Job Type', dict(method = 'frappe.social.doctype.energy_point_log.energy_point_log.send_weekly_summary')) + job.db_set('last_execution', '2019-01-01 00:00:00') + self.assertTrue(job.is_event_due(get_datetime('2019-01-06 00:00:01'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-02 00:00:06'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-05 23:59:59'))) + + def test_monthly_job(self): + job = frappe.get_doc('Scheduled Job Type', dict(method = 'frappe.email.doctype.auto_email_report.auto_email_report.send_monthly')) + job.db_set('last_execution', '2019-01-01 00:00:00') + self.assertTrue(job.is_event_due(get_datetime('2019-02-01 00:00:01'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-15 00:00:06'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-31 23:59:59'))) + + def test_cron_job(self): + # runs every 15 mins + job = frappe.get_doc('Scheduled Job Type', dict(method = 'frappe.oauth.delete_oauth2_data')) + job.db_set('last_execution', '2019-01-01 00:00:00') + self.assertTrue(job.is_event_due(get_datetime('2019-01-01 00:15:01'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-01 00:05:06'))) + self.assertFalse(job.is_event_due(get_datetime('2019-01-01 00:14:59'))) diff --git a/frappe/core/doctype/system_settings/system_settings.json b/frappe/core/doctype/system_settings/system_settings.json index 55bc5d49db..9e8bc8c3fd 100644 --- a/frappe/core/doctype/system_settings/system_settings.json +++ b/frappe/core/doctype/system_settings/system_settings.json @@ -1,4 +1,5 @@ { + "actions": [], "creation": "2014-04-17 16:53:52.640856", "doctype": "DocType", "document_type": "System", @@ -21,7 +22,7 @@ "backup_limit", "background_workers", "enable_scheduler", - "scheduler_last_event", + "dormant_days", "permissions", "apply_strict_user_permissions", "column_break_21", @@ -168,13 +169,6 @@ "hidden": 1, "label": "Enable Scheduled Jobs" }, - { - "fieldname": "scheduler_last_event", - "fieldtype": "Data", - "hidden": 1, - "label": "Scheduler Last Event", - "report_hide": 1 - }, { "collapsible": 1, "fieldname": "permissions", @@ -397,11 +391,18 @@ "fieldname": "allow_guests_to_upload_files", "fieldtype": "Check", "label": "Allow Guests to Upload Files" + }, + { + "default": "4", + "description": "Will run scheduled jobs only once a day for inactive sites. Default 4 days if set to 0.", + "fieldname": "dormant_days", + "fieldtype": "Int", + "label": "Run Jobs only Daily if Inactive For (Days)" } ], "icon": "fa fa-cog", "issingle": 1, - "modified": "2019-08-16 08:26:45.936626", + "modified": "2019-09-24 10:04:28.807388", "modified_by": "Administrator", "module": "Core", "name": "System Settings", diff --git a/frappe/core/doctype/test_runner/_test_test_runner.js b/frappe/core/doctype/test_runner/_test_test_runner.js deleted file mode 100644 index 0b0bd9a98b..0000000000 --- a/frappe/core/doctype/test_runner/_test_test_runner.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -// rename this file from _test_[name] to test_[name] to activate -// and remove above this line - -QUnit.test("test: Test Runner", function (assert) { - let done = assert.async(); - - // number of asserts - assert.expect(1); - - frappe.run_serially('Test Runner', [ - // insert a new Test Runner - () => frappe.tests.make([ - // values to be set - {key: 'value'} - ]), - () => { - assert.equal(cur_frm.doc.key, 'value'); - }, - () => done() - ]); - -}); diff --git a/frappe/core/doctype/test_runner/test_runner.js b/frappe/core/doctype/test_runner/test_runner.js deleted file mode 100644 index d08a3626a3..0000000000 --- a/frappe/core/doctype/test_runner/test_runner.js +++ /dev/null @@ -1,87 +0,0 @@ -// 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(); - $("
Loading...
").appendTo(wrapper); - - // all tests - frappe.call({ - method: 'frappe.core.doctype.test_runner.test_runner.get_test_js', - args: { test_path: frm.doc.module_path } - }).always((data) => { - $("").appendTo(wrapper.empty()); - frm.events.run_tests(frm, data.message); - resolve(); - }); - }); - }); - - }, - run_tests: function(frm, files) { - frappe.flags.in_test = true; - 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); - }); - - QUnit.config.notrycatch = true; - - window.onerror = function(msg, url, lineNo, columnNo, error) { - console.log(error.stack); // eslint-disable-line - $('').appendTo($('body')); - }; - - 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)); - - details.assertions.map(a => { - // eslint-disable-next-line - console.log(`${a.result ? '✔' : '✗'} ${a.message}`); - }); - - }); - QUnit.load(); - - QUnit.done(({ total, failed, passed, runtime }) => { - // flag for selenium that test is done - - console.log( `Total: ${total}, Failed: ${failed}, Passed: ${passed}, Runtime: ${runtime}` ); // eslint-disable-line - - if(failed) { - console.log('Tests Failed'); // eslint-disable-line - } else { - console.log('Tests Passed'); // eslint-disable-line - } - frappe.set_route('Form', 'Test Runner', 'Test Runner'); - - $('').appendTo($('body')); - - }); - }); - - } -}); diff --git a/frappe/core/doctype/test_runner/test_runner.json b/frappe/core/doctype/test_runner/test_runner.json deleted file mode 100644 index ccc1361dc9..0000000000 --- a/frappe/core/doctype/test_runner/test_runner.json +++ /dev/null @@ -1,152 +0,0 @@ -{ - "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": "app", - "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": "App", - "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-19 03:22:33.221169", - "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 -} \ No newline at end of file diff --git a/frappe/core/doctype/test_runner/test_runner.py b/frappe/core/doctype/test_runner/test_runner.py deleted file mode 100644 index 2961e9f38b..0000000000 --- a/frappe/core/doctype/test_runner/test_runner.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- 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(test_path=None): - '''Get test + data for app, example: app/tests/ui/test_name.js''' - if not test_path: - test_path = frappe.db.get_single_value('Test Runner', 'module_path') - test_js = [] - - # split - app, test_path = test_path.split(os.path.sep, 1) - - # now full path - test_path = frappe.get_app_path(app, test_path) - - def add_file(path): - with open(path, 'r') as fileobj: - test_js.append(dict( - script = fileobj.read() - )) - - # add test_lib.js - add_file(frappe.get_app_path('frappe', 'tests', 'ui', 'data', 'test_lib.js')) - add_file(test_path) - - return test_js - diff --git a/frappe/core/doctype/user/user.py b/frappe/core/doctype/user/user.py index 7de2bb20e5..35495954b4 100644 --- a/frappe/core/doctype/user/user.py +++ b/frappe/core/doctype/user/user.py @@ -97,7 +97,9 @@ class User(Document): self.share_with_self() clear_notifications(user=self.name) frappe.clear_cache(user=self.name) - self.send_password_notification(self.__new_password) + if self.__new_password: + self.send_password_notification(self.__new_password) + self.reset_password_key = '' create_contact(self, ignore_mandatory=True) if self.name not in ('Administrator', 'Guest') and not self.user_image: frappe.enqueue('frappe.core.doctype.user.user.update_gravatar', name=self.name) @@ -1071,4 +1073,4 @@ def generate_keys(user): user_details.save() return {"api_secret": api_secret} - frappe.throw(frappe._("Not Permitted"), frappe.PermissionError) \ No newline at end of file + frappe.throw(frappe._("Not Permitted"), frappe.PermissionError) diff --git a/frappe/core/page/background_jobs/background_jobs.html b/frappe/core/page/background_jobs/background_jobs.html index c5d598ccd3..08177ecf8a 100644 --- a/frappe/core/page/background_jobs/background_jobs.html +++ b/frappe/core/page/background_jobs/background_jobs.html @@ -11,7 +11,7 @@ {% for j in jobs %}Notes:
\n\ndata-fieldtype and data-fieldnamevaluesection-breakcolumn-break1. Left align integers
\n\n[data-fieldtype=\"Int\"] .value { text-left: left; }\n\n1. Add border to sections except the last section
\n\n.section-break { padding: 30px 0px; border-bottom: 1px solid #eee; }\n.section-break:last-child { padding-bottom: 0px; border-bottom: 0px; }\n",
- "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,
- "translatable": 0,
- "unique": 0
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "depends_on": "custom_format",
- "fetch_if_empty": 0,
- "fieldname": "section_break_13",
- "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,
- "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,
- "translatable": 0,
- "unique": 0
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "depends_on": "custom_format",
- "fetch_if_empty": 0,
- "fieldname": "print_format_help",
- "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": "Print Format Help",
- "length": 0,
- "no_copy": 0,
- "options": "Print itemsFormats are rendered on the server side using the Jinja Templating Language. All forms have access to the doc object which contains information about the document that is being formatted. You can also access common utilities via the frappe module.
For styling, the Boostrap CSS framework is provided and you can enjoy the full range of classes.
\n<h3>{{ doc.select_print_heading or \"Invoice\" }}</h3>\n<div class=\"row\">\n\t<div class=\"col-md-3 text-right\">Customer Name</div>\n\t<div class=\"col-md-9\">{{ doc.customer_name }}</div>\n</div>\n<div class=\"row\">\n\t<div class=\"col-md-3 text-right\">Date</div>\n\t<div class=\"col-md-9\">{{ doc.get_formatted(\"invoice_date\") }}</div>\n</div>\n<table class=\"table table-bordered\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<th>Sr</th>\n\t\t\t<th>Item Name</th>\n\t\t\t<th>Description</th>\n\t\t\t<th class=\"text-right\">Qty</th>\n\t\t\t<th class=\"text-right\">Rate</th>\n\t\t\t<th class=\"text-right\">Amount</th>\n\t\t</tr>\n\t\t{%- for row in doc.items -%}\n\t\t<tr>\n\t\t\t<td style=\"width: 3%;\">{{ row.idx }}</td>\n\t\t\t<td style=\"width: 20%;\">\n\t\t\t\t{{ row.item_name }}\n\t\t\t\t{% if row.item_code != row.item_name -%}\n\t\t\t\t<br>Item Code: {{ row.item_code}}\n\t\t\t\t{%- endif %}\n\t\t\t</td>\n\t\t\t<td style=\"width: 37%;\">\n\t\t\t\t<div style=\"border: 0px;\">{{ row.description }}</div></td>\n\t\t\t<td style=\"width: 10%; text-align: right;\">{{ row.qty }} {{ row.uom or row.stock_uom }}</td>\n\t\t\t<td style=\"width: 15%; text-align: right;\">{{\n\t\t\t\trow.get_formatted(\"rate\", doc) }}</td>\n\t\t\t<td style=\"width: 15%; text-align: right;\">{{\n\t\t\t\trow.get_formatted(\"amount\", doc) }}</td>\n\t\t</tr>\n\t\t{%- endfor -%}\n\t</tbody>\n</table>\ndoc.get_formatted(\"[fieldname]\", [parent_doc]) | \n\t\t\tGet document value formatted as Date, Currency etc. Pass parent doc for curreny type fields. | \n\t\t
frappe.db.get_value(\"[doctype]\", \"[name]\", \"fieldname\") | \n\t\t\tGet value from another document. | \n\t\t
Notes:
\n\ndata-fieldtype and data-fieldnamevaluesection-breakcolumn-break1. Left align integers
\n\n[data-fieldtype=\"Int\"] .value { text-left: left; }\n\n1. Add border to sections except the last section
\n\n.section-break { padding: 30px 0px; border-bottom: 1px solid #eee; }\n.section-break:last-child { padding-bottom: 0px; border-bottom: 0px; }\n"
+ },
+ {
+ "depends_on": "custom_format",
+ "fieldname": "section_break_13",
+ "fieldtype": "Section Break"
+ },
+ {
+ "depends_on": "custom_format",
+ "fieldname": "print_format_help",
+ "fieldtype": "HTML",
+ "label": "Print Format Help",
+ "options": "Print itemsFormats are rendered on the server side using the Jinja Templating Language. All forms have access to the doc object which contains information about the document that is being formatted. You can also access common utilities via the frappe module.
For styling, the Boostrap CSS framework is provided and you can enjoy the full range of classes.
\n<h3>{{ doc.select_print_heading or \"Invoice\" }}</h3>\n<div class=\"row\">\n\t<div class=\"col-md-3 text-right\">Customer Name</div>\n\t<div class=\"col-md-9\">{{ doc.customer_name }}</div>\n</div>\n<div class=\"row\">\n\t<div class=\"col-md-3 text-right\">Date</div>\n\t<div class=\"col-md-9\">{{ doc.get_formatted(\"invoice_date\") }}</div>\n</div>\n<table class=\"table table-bordered\">\n\t<tbody>\n\t\t<tr>\n\t\t\t<th>Sr</th>\n\t\t\t<th>Item Name</th>\n\t\t\t<th>Description</th>\n\t\t\t<th class=\"text-right\">Qty</th>\n\t\t\t<th class=\"text-right\">Rate</th>\n\t\t\t<th class=\"text-right\">Amount</th>\n\t\t</tr>\n\t\t{%- for row in doc.items -%}\n\t\t<tr>\n\t\t\t<td style=\"width: 3%;\">{{ row.idx }}</td>\n\t\t\t<td style=\"width: 20%;\">\n\t\t\t\t{{ row.item_name }}\n\t\t\t\t{% if row.item_code != row.item_name -%}\n\t\t\t\t<br>Item Code: {{ row.item_code}}\n\t\t\t\t{%- endif %}\n\t\t\t</td>\n\t\t\t<td style=\"width: 37%;\">\n\t\t\t\t<div style=\"border: 0px;\">{{ row.description }}</div></td>\n\t\t\t<td style=\"width: 10%; text-align: right;\">{{ row.qty }} {{ row.uom or row.stock_uom }}</td>\n\t\t\t<td style=\"width: 15%; text-align: right;\">{{\n\t\t\t\trow.get_formatted(\"rate\", doc) }}</td>\n\t\t\t<td style=\"width: 15%; text-align: right;\">{{\n\t\t\t\trow.get_formatted(\"amount\", doc) }}</td>\n\t\t</tr>\n\t\t{%- endfor -%}\n\t</tbody>\n</table>\ndoc.get_formatted(\"[fieldname]\", [parent_doc]) | \n\t\t\tGet document value formatted as Date, Currency etc. Pass parent doc for curreny type fields. | \n\t\t
frappe.db.get_value(\"[doctype]\", \"[name]\", \"fieldname\") | \n\t\t\tGet value from another document. | \n\t\t
The letter head will appear across all print formats and PDFs
\nKeep it web friendly as 1024px by 128px
", + "slide_fields": [ + { + "align": "center", + "fieldname": "letterhead", + "fieldtype": "Attach Image", + "label": "Attach Letterhead", + "options": "image", + "reqd": 0 + } + ], + "slide_order": 20, + "slide_title": "Company Letter Head", + "slide_type": "Create" +} \ No newline at end of file diff --git a/frappe/printing/page/print_format_builder/print_format_builder.js b/frappe/printing/page/print_format_builder/print_format_builder.js index 8605fb762e..f9bb524d54 100644 --- a/frappe/printing/page/print_format_builder/print_format_builder.js +++ b/frappe/printing/page/print_format_builder/print_format_builder.js @@ -695,7 +695,8 @@ frappe.PrintFormatBuilder = Class.extend({ { fieldname: "content", fieldtype: "Code", - label: label + label: label, + options: "HTML" }, { fieldname: "help", diff --git a/frappe/public/build.json b/frappe/public/build.json index 32a2cfd223..76d6d74563 100755 --- a/frappe/public/build.json +++ b/frappe/public/build.json @@ -148,6 +148,7 @@ "public/js/frappe/ui/page.html", "public/js/frappe/ui/page.js", "public/js/frappe/ui/slides.js", + "public/js/frappe/ui/onboarding_dialog.js", "public/js/frappe/ui/find.js", "public/js/frappe/ui/iconbar.js", "public/js/frappe/form/layout.js", diff --git a/frappe/public/css/desk.css b/frappe/public/css/desk.css index 8e43be12ea..6ddf93df6a 100644 --- a/frappe/public/css/desk.css +++ b/frappe/public/css/desk.css @@ -1158,16 +1158,6 @@ input[type="checkbox"]:focus { color: #fff; border-color: #b1bdca; } -.user-progress-dialog .slides-progress { +.onboarding-dialog .slides-progress { margin-top: 15px; } -.user-progress-dialog .done-state .check-container { - font-size: 64px; - margin: 40px; -} -.user-progress-dialog .done-state .title { - font-weight: normal; -} -.user-progress-dialog .done-state .help-links a { - margin: 0px 10px; -} diff --git a/frappe/public/images/ui-states/empty.png b/frappe/public/images/ui-states/empty.png new file mode 100644 index 0000000000..de2a893508 Binary files /dev/null and b/frappe/public/images/ui-states/empty.png differ diff --git a/frappe/public/js/frappe/desk.js b/frappe/public/js/frappe/desk.js index 4fbea6684f..92194acdca 100644 --- a/frappe/public/js/frappe/desk.js +++ b/frappe/public/js/frappe/desk.js @@ -88,6 +88,9 @@ frappe.Application = Class.extend({ } this.show_update_available(); + if (frappe.boot.is_first_startup) { + this.setup_onboarding_wizard(); + } if(frappe.ui.startup_setup_dialog && !frappe.boot.setup_complete) { frappe.ui.startup_setup_dialog.pre_show(); @@ -482,6 +485,20 @@ frappe.Application = Class.extend({ }); }, + setup_onboarding_wizard: () => { + frappe.call('frappe.desk.doctype.onboarding_slide.onboarding_slide.get_onboarding_slides').then(res => { + if (res.message) { + let slides = res.message; + if (slides.length) { + this.progress_dialog = new frappe.setup.OnboardingDialog({ + slides: slides + }); + this.progress_dialog.show(); + } + } + }); + }, + setup_analytics: function() { if(window.mixpanel) { window.mixpanel.identify(frappe.session.user); diff --git a/frappe/public/js/frappe/form/controls/autocomplete.js b/frappe/public/js/frappe/form/controls/autocomplete.js index 026cdbec25..0b31776caf 100644 --- a/frappe/public/js/frappe/form/controls/autocomplete.js +++ b/frappe/public/js/frappe/form/controls/autocomplete.js @@ -10,13 +10,7 @@ frappe.ui.form.ControlAutocomplete = frappe.ui.form.ControlData.extend({ set_options() { if (this.df.options) { let options = this.df.options || []; - if (typeof options === 'string') { - options = options.split('\n'); - } - if (typeof options[0] === 'string') { - options = options.map(o => ({ label: o, value: o })); - } - this._data = options; + this._data = this.parse_options(options); } }, @@ -100,6 +94,9 @@ frappe.ui.form.ControlAutocomplete = frappe.ui.form.ControlData.extend({ }, validate(value) { + if (this.df.ignore_validation) { + return value || ''; + } let valid_values = this.awesomplete._list.map(d => d.value); if (!valid_values.length) { return value; @@ -111,11 +108,22 @@ frappe.ui.form.ControlAutocomplete = frappe.ui.form.ControlData.extend({ } }, + parse_options(options) { + if (typeof options === 'string') { + options = options.split('\n'); + } + if (typeof options[0] === 'string') { + options = options.map(o => ({ label: o, value: o })); + } + return options; + }, + get_data() { return this._data || []; }, set_data(data) { + data = this.parse_options(data); if (this.awesomplete) { this.awesomplete.list = data; } diff --git a/frappe/public/js/frappe/form/controls/comment.js b/frappe/public/js/frappe/form/controls/comment.js index eba82fd7b2..a64df56bca 100644 --- a/frappe/public/js/frappe/form/controls/comment.js +++ b/frappe/public/js/frappe/form/controls/comment.js @@ -1,7 +1,7 @@ import Quill from 'quill'; import Mention from './quill-mention/quill.mention'; -Quill.register('modules/mention', Mention); +Quill.register('modules/mention', Mention, true); frappe.ui.form.ControlComment = frappe.ui.form.ControlTextEditor.extend({ make_wrapper() { diff --git a/frappe/public/js/frappe/form/controls/currency.js b/frappe/public/js/frappe/form/controls/currency.js index 196ae5fd43..5a50ad9de6 100644 --- a/frappe/public/js/frappe/form/controls/currency.js +++ b/frappe/public/js/frappe/form/controls/currency.js @@ -1,29 +1,9 @@ frappe.ui.form.ControlCurrency = frappe.ui.form.ControlFloat.extend({ - eval_expression: function(value) { - if (typeof value ==='string' && value.match(/^[0-9+-/* ]+$/)) { - // Removes seperator - value = strip_number_groups(value, this.get_number_format()); - - try { - return eval(value); - } catch (e) { - return value; - } - } - // If not string - return value; - }, - format_for_input: function(value) { - var formatted_value = format_number(parseFloat(value), this.get_number_format(), this.get_precision()); + var formatted_value = format_number(value, this.get_number_format(), this.get_precision()); return isNaN(parseFloat(value)) ? "" : formatted_value; }, - get_number_format: function() { - var currency = frappe.meta.get_field_currency(this.df, this.get_doc()); - return get_number_format(currency); - }, - get_precision: function() { // always round based on field precision or currency's precision // this method is also called in this.parse() diff --git a/frappe/public/js/frappe/form/controls/float.js b/frappe/public/js/frappe/form/controls/float.js index d32f29e28a..027cfebc2a 100644 --- a/frappe/public/js/frappe/form/controls/float.js +++ b/frappe/public/js/frappe/form/controls/float.js @@ -1,10 +1,7 @@ frappe.ui.form.ControlFloat = frappe.ui.form.ControlInt.extend({ parse: function(value) { value = this.eval_expression(value); - return isNaN(parseFloat(value)) ? null : flt(value, this.get_precision(), - // While parsing currency, get_number_format passes currency's number_format - // In case of parsing float, it passes global number_format - this.get_number_format()); + return isNaN(parseFloat(value)) ? null : flt(value, this.get_precision()); }, format_for_input: function(value) { @@ -12,13 +9,13 @@ frappe.ui.form.ControlFloat = frappe.ui.form.ControlInt.extend({ if (this.df.fieldtype==="Float" && this.df.options && this.df.options.trim()) { number_format = this.get_number_format(); } - var formatted_value = format_number(parseFloat(value), number_format, this.get_precision()); + var formatted_value = format_number(value, number_format, this.get_precision()); return isNaN(parseFloat(value)) ? "" : formatted_value; }, get_number_format: function() { - // In case of 'Float' field currency's number_format shouldn't be used for formatting - return get_number_format(); + var currency = frappe.meta.get_field_currency(this.df, this.get_doc()); + return get_number_format(currency); }, get_precision: function() { diff --git a/frappe/public/js/frappe/form/controls/int.js b/frappe/public/js/frappe/form/controls/int.js index ead48a996c..5639e5f132 100644 --- a/frappe/public/js/frappe/form/controls/int.js +++ b/frappe/public/js/frappe/form/controls/int.js @@ -20,20 +20,18 @@ frappe.ui.form.ControlInt = frappe.ui.form.ControlData.extend({ }); }, eval_expression: function(value) { - if (typeof value==='string' - && value.match(/^[0-9+-/* ]+$/) - // strings with commas are evaluated incorrectly - // for e.g 47,186.00 -> 186 - && !value.includes(',')) { - try { - return eval(value); - } catch (e) { - // bad expression - return value; + if (typeof value === 'string') { + if (value.match(/^[0-9\+\-\/\* ]+$/)) { + // If it is a string containing operators + try { + return eval(value); + } catch (e) { + // bad expression + return value; + } } - } else { - return value; } + return value; }, parse: function(value) { return cint(this.eval_expression(value), null); diff --git a/frappe/public/js/frappe/form/controls/multiselect_list.js b/frappe/public/js/frappe/form/controls/multiselect_list.js index 0313ee70d7..3e8dc21dca 100644 --- a/frappe/public/js/frappe/form/controls/multiselect_list.js +++ b/frappe/public/js/frappe/form/controls/multiselect_list.js @@ -17,6 +17,7 @@ frappe.ui.form.ControlMultiSelectList = frappe.ui.form.ControlData.extend({ this.$list_wrapper = $(template); this.$input = $(''); + this.input = this.$input.get(0); this.$list_wrapper.prependTo(this.input_area); this.$filter_input = this.$list_wrapper.find('input'); this.$list_wrapper.on('click', '.dropdown-menu', e => { diff --git a/frappe/public/js/frappe/form/controls/quill-mention/blots/mention.js b/frappe/public/js/frappe/form/controls/quill-mention/blots/mention.js index 26485adcc0..1c5787f854 100644 --- a/frappe/public/js/frappe/form/controls/quill-mention/blots/mention.js +++ b/frappe/public/js/frappe/form/controls/quill-mention/blots/mention.js @@ -35,4 +35,4 @@ MentionBlot.blotName = 'mention'; MentionBlot.tagName = 'span'; MentionBlot.className = 'mention'; -Quill.register(MentionBlot); +Quill.register(MentionBlot, true); diff --git a/frappe/public/js/frappe/form/controls/quill-mention/quill.mention.js b/frappe/public/js/frappe/form/controls/quill-mention/quill.mention.js index 0b50ffd758..80ee5a6c6d 100644 --- a/frappe/public/js/frappe/form/controls/quill-mention/quill.mention.js +++ b/frappe/public/js/frappe/form/controls/quill-mention/quill.mention.js @@ -361,6 +361,6 @@ class Mention { } } -Quill.register('modules/mention', Mention); +Quill.register('modules/mention', Mention, true); export default Mention; diff --git a/frappe/public/js/frappe/form/controls/text_editor.js b/frappe/public/js/frappe/form/controls/text_editor.js index 1b9078ae03..b35c92c1ae 100644 --- a/frappe/public/js/frappe/form/controls/text_editor.js +++ b/frappe/public/js/frappe/form/controls/text_editor.js @@ -36,7 +36,7 @@ class MyLink extends Link { } } -Quill.register(MyLink); +Quill.register(MyLink, true); // image uploader const Uploader = Quill.import('modules/uploader'); diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index 5a983986d8..2f30228aff 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -111,6 +111,7 @@ frappe.ui.form.Form = class FrappeForm { $("body").attr("data-sidebar", 1); } this.setup_file_drop(); + this.setup_doctype_actions(); this.setup_done = true; } @@ -319,6 +320,29 @@ frappe.ui.form.Form = class FrappeForm { } } + // sets up the refresh event for custom buttons + // added via configuration + setup_doctype_actions() { + if (this.meta.actions) { + for (let action of this.meta.actions) { + frappe.ui.form.on(this.doctype, 'refresh', () => { + if (!this.is_new()) { + this.add_custom_button(action.label, () => { + if (action.action_type==='Server Action') { + frappe.xcall(action.action, {doc: this.doc}).then(() => { + frappe.msgprint({ + message: __('{} Complete', [action.label]), + alert: true + }); + }); + } + }, action.group); + } + }); + } + } + } + switch_doc(docname) { // record switch if(this.docname != docname && (!this.meta.in_dialog || this.in_form) && !this.meta.istable) { @@ -328,7 +352,11 @@ frappe.ui.form.Form = class FrappeForm { } } // reset visible columns, since column headings can change in different docs - this.grids.forEach(grid_obj => grid_obj.grid.visible_columns = null); + this.grids.forEach(grid_obj => { + grid_obj.grid.visible_columns = null + // reset page number to 1 + grid_obj.grid.grid_pagination.go_to_page(1); + }); frappe.ui.form.close_grid_form(); this.docname = docname; } diff --git a/frappe/public/js/frappe/form/grid.js b/frappe/public/js/frappe/form/grid.js index 3de62daf64..6da742638a 100644 --- a/frappe/public/js/frappe/form/grid.js +++ b/frappe/public/js/frappe/form/grid.js @@ -2,6 +2,7 @@ // MIT License. See license.txt import GridRow from "./grid_row"; +import GridPagination from './grid_pagination'; frappe.ui.form.get_open_grid_form = function() { return $(".grid-row-open").data("grid_row"); @@ -47,8 +48,8 @@ export default class Grid { return false; } } + make() { - var me = this; let template = `